Identifiers
Auto-increment ids, UUIDs and ULIDs, natural keys, and what a sequential id tells anyone who sees it.
#[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column]Every entity needs an identifier: the property, marked #[ORM\Id], that tells one row from another. Doctrine uses it to track objects it is managing and to write the WHERE clause of every update. There are three ways to get one.
Generated by the database
#[ORM\GeneratedValue] with no argument is shorthand for #[ORM\GeneratedValue(strategy: 'AUTO')]. Doctrine picks the strategy the platform prefers, which on MySQL and PostgreSQL means an auto-increment column. The value does not exist until the INSERT runs, so the property stays null between persist() and flush().
Generated in PHP
The symfony/uid component provides Uuid and Ulid value objects, and the Doctrine bridge provides the column types UuidType and UlidType. With strategy: 'CUSTOM' and #[ORM\CustomIdGenerator] the identifier is created before the row is written, so you can use it immediately, for example to name a file or publish a message. UUIDv7 and ULID both start with a timestamp, so new values sort near each other and the index does not fragment the way random UUIDv4 values do.
A natural key
Sometimes a real value already identifies the row: a currency code, a country code. Mark it #[ORM\Id] and leave out #[ORM\GeneratedValue]. This only holds up if the value is defined outside your system, never changes and is never reused.
What a sequential id leaks
An id in a URL is public. /invoices/412 says there are at least 412 invoices, invites someone to try 413, and two ids a week apart reveal your rate of business. An opaque identifier removes the question.
Example
<?php
// src/Entity/Invoice.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class Invoice
{
// CUSTOM means "ask this generator", and the generator runs during
// persist(), so the id exists before the INSERT.
//
// Shorthand and longhand of the same attribute:
// #[ORM\CustomIdGenerator('doctrine.uuid_generator')]
// #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
//
// The service is used rather than the generator class directly because the
// service honours the UUID version configured for the application.
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator('doctrine.uuid_generator')]
private ?Uuid $id = null;
// A human-facing reference is not the identifier. It is a unique column
// and it is allowed to change; the id is not.
#[ORM\Column(length: 20, unique: true)]
private string $reference;
public function __construct(string $reference)
{
$this->reference = $reference;
}
public function getId(): ?Uuid
{
return $this->id;
}
}When to use it
- Give an entity an id you can use before the row is written, to name an uploaded file after it.
- Replace a sequential id in public URLs so the id no longer reveals row counts.
- Model reference data such as currencies or countries on its own ISO code.
More examples
A ULID identifier
A ULID is 26 characters, base32, URL-safe, and starts with a millisecond timestamp, so values created together land together in the index. Reach for it when you want the index behaviour and the shorter string; reach for a UUID when another system in the conversation already speaks UUID.
<?php
// src/Entity/Shipment.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
#[ORM\Entity]
class Shipment
{
#[ORM\Id]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private ?Ulid $id = null;
#[ORM\Column(length: 120)]
private string $destination;
public function __construct(string $destination)
{
$this->destination = $destination;
}
public function getId(): ?Ulid
{
return $this->id;
}
}A natural key, and no generator
For reference data a natural key removes a pointless surrogate column and makes rows readable. The test is strict: if the value could ever be corrected, it is not an identifier, because changing an id means rewriting every row that points at it. More than one property may carry #[ORM\Id], which gives a composite key; find() then takes an array of the id values.
<?php
// src/Entity/Currency.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Currency
{
// No #[ORM\GeneratedValue]: the value is assigned by you, not generated.
// 'EUR' is an ISO 4217 code - defined elsewhere, stable, never reused.
#[ORM\Id]
#[ORM\Column(length: 3)]
private string $code;
#[ORM\Column(length: 60)]
private string $name;
#[ORM\Column]
private int $minorUnitScale;
public function __construct(string $code, string $name, int $minorUnitScale)
{
$this->code = $code;
$this->name = $name;
$this->minorUnitScale = $minorUnitScale;
}
public function getCode(): string
{
return $this->code;
}
}Looking one up by identifier
GET /invoices/01HF... returns 200 with the invoice id as a string; an id that is not a valid UUID, and one that is valid but absent, both return 404. The point is the conversion: the column type is a Uuid object, so find() needs a Uuid, not the raw string from the URL.
<?php
// src/Controller/InvoiceController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Invoice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
final class InvoiceController extends AbstractController
{
// The entity manager is injected, never fetched from the container.
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/invoices/{id}', name: 'app_invoice_show', methods: ['GET'])]
public function show(string $id): JsonResponse
{
// An unparseable id is a 404, not a 500.
if (!Uuid::isValid($id)) {
throw $this->createNotFoundException('Unknown invoice.');
}
$invoice = $this->entityManager->find(Invoice::class, Uuid::fromString($id));
if (null === $invoice) {
throw $this->createNotFoundException('Unknown invoice.');
}
return $this->json(['id' => (string) $invoice->getId()]);
}
}
Discussion