DEV Community

Cover image for Beyond AUTO_INCREMENT: Mastering Symfony Uid in Distributed Architectures
Matt Mochalkin
Matt Mochalkin

Posted on

Beyond AUTO_INCREMENT: Mastering Symfony Uid in Distributed Architectures

In the monolithic era, id INT AUTO_INCREMENT was the undisputed king of database identifiers. It was simple, small (4 bytes) and naturally sorted. But as we moved toward distributed systems, microservices and high-concurrency APIs, the integer ID began to show its cracks. It leaks business intelligence (competitors can guess your volume), it complicates database merging/sharding and it requires a round-trip to the database just to know the identity of a new object.

While most developers stop at “let’s just use UUID v4” they miss the architectural depth this component offers. In this guide, we will explore advanced implementations of symfony/uid in Symfony 7.3, moving beyond basic generation to tackle database fragmentation, cursor-based pagination and deterministic idempotency.

Installation & Prerequisites

We are using Symfony 7.3 (assuming modern 7.x features) and Doctrine ORM 3.

Ensure your composer.json reflects the following dependencies. We strictly use symfony/uid and its Doctrine integration.

composer require symfony/uid symfony/doctrine-bridge doctrine/orm
Enter fullscreen mode Exit fullscreen mode

The Database Performance Fix: UUID v7

The most common mistake in modern Symfony apps is using UUID v4 as a primary key.

The Problem: Index Fragmentation

UUID v4 is completely random. When you insert a random ID into a MySQL/PostgreSQL clustered index (B-Tree), the database has to constantly rebalance the tree to insert the new page in the “middle” of the index. This causes massive page splitting and fragmentation, killing insert performance as the table grows.

The Solution: UUID v7

UUID v7 (standardized in RFC 9562 and fully supported in Symfony 7.x) combines a Unix timestamp (millisecond precision) with random data. This makes them strictly monotonic (time-ordered). They insert at the end of the B-Tree, just like an auto-increment integer, but retain the uniqueness and distributed nature of a UUID.

Implementation

Do not rely on Doctrine’s @GeneratedValue magic, which often defaults to v4. Instead, initialize the ID in the constructor. This creates a “Persistence Ignorant” entity where the ID is available before the flush.

//src/Entity/Transaction.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV7;

#[ORM\Entity]
#[ORM\Table(name: 'transactions')]
class Transaction
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid', unique: true)]
    private Uuid $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $status;

    public function __construct(string $status)
    {
        // Explicitly generate v7 for time-ordered performance
        $this->id = new UuidV7(); 
        $this->status = $status;
    }

    public function getId(): Uuid
    {
        return $this->id;
    }

    // ... getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Doctrine’s type: ‘uuid’ automatically handles the conversion. By default, it stores as CHAR(36) (readable) or BINARY(16) (efficient) depending on your platform configuration. For high-volume systems, always configure Doctrine to use BINARY(16) to save 55% of storage space per ID.

Verification

  1. Generate a migration: php bin/console make:migration
  2. Inspect the SQL. You should see the column defined.
  3. Run a test script creating 10 entities. Check the DB; the IDs should be sequentially increasing (the first characters will be identical).

High-Performance Pagination with ULIDs

Pagination using OFFSET and LIMIT is a performance killer. The database must count and discard thousands of rows to reach page 100. The solution is Keyset Pagination (Cursor-based), but this usually requires a unique, sortable column (like created_at). Timestamps are risky because two events can happen at the exact same microsecond.

ULIDs (Universally Unique Lexicographically Sortable Identifier) are 128-bit compatible identifiers that are lexicographically sortable.

The Strategy

We will use a ULID as the primary key. Because ULIDs are sortable strings, we can simply say: “Give me 10 items where ID > [Last Seen ULID]”.

//src/Entity/EventLog.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Ulid;

#[ORM\Entity(repositoryClass: \App\Repository\EventLogRepository::class)]
class EventLog
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid', unique: true)]
    private Ulid $id;

    #[ORM\Column(type: 'text')]
    private string $payload;

    public function __construct(string $payload)
    {
        $this->id = new Ulid();
        $this->payload = $payload;
    }

    public function getId(): Ulid
    {
        return $this->id;
    }
}
Enter fullscreen mode Exit fullscreen mode
//src/Repository/EventLogRepository.php

namespace App\Repository;

use App\Entity\EventLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Uid\Ulid;

class EventLogRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, EventLog::class);
    }

    /**
     * @return EventLog[]
     */
    public function findAfterCursor(?Ulid $cursor, int $limit = 20): array
    {
        $qb = $this->createQueryBuilder('e')
            ->orderBy('e.id', 'ASC')
            ->setMaxResults($limit);

        if ($cursor) {
            // Efficient index seek
            $qb->andWhere('e.id > :cursor')
               ->setParameter('cursor', $cursor->toBinary()); 
               // Note: Depending on your DB type setup, you might pass the object or binary.
               // The symfony bridge usually handles the object, but explicit binary prevents ambiguity.
        }

        return $qb->getQuery()->getResult();
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this matters? This query hits the Primary Key index directly. It is O(1) complexity regardless of whether you are on page 1 or page 1,000,000.

Idempotency & Deterministic IDs (UUID v5)

In distributed systems (e.g., CQRS or Event Sourcing), you often receive the same message twice (at-least-once delivery). If you generate a random ID every time you process an import, you create duplicates.

UUID v5 is deterministic. Given a Namespace and a Name (input string), it always produces the same UUID.

Scenario: Importing Products from CSV

You are importing products from a legacy system that has SKUs, but no UUIDs. You want to ensure that if you run the import script twice, you don’t create duplicate rows, but update the existing “identity”.

//src/Service/ProductIdGenerator.php

namespace App\Service;

use Symfony\Component\Uid\Uuid;

class ProductIdGenerator
{
    // A random UUID v4 constant serving as our private Namespace
    // Generated once via `php bin/console uuid:generate`
    private const PRODUCT_NAMESPACE = 'd2e294c8-3829-410e-8baf-7359a58f7051';

    public function generateForSku(string $sku): Uuid
    {
        // v5 uses SHA-1 hashing to ensure uniqueness based on input
        return Uuid::v5(Uuid::fromString(self::PRODUCT_NAMESPACE), $sku);
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage in a Command:

// Inside a command or service
$sku = 'WIDGET-ABC-123';
$deterministicId = $this->idGenerator->generateForSku($sku);

// $deterministicId will ALWAYS be '...' for SKU 'WIDGET-ABC-123'
// You can now safely use:
// $em->getReference(Product::class, $deterministicId);
Enter fullscreen mode Exit fullscreen mode

Vanity URLs: Shortening UIDs for Humans

UUIDs (123e4567-e89b-12d3-a456–426614174000) are ugly in URLs. YouTube uses Base64/Base58. Symfony Uid has built-in support for Base58 (Bitcoin style, no ambiguous characters like 0/O, I/l) and Base32.

We can create a Symfony Serializer Normalizer that automatically converts UIDs to Base58 when sending JSON responses and back to UUIDs when receiving requests.

// src/Serializer/UuidBase58Normalizer.php

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class UuidBase58Normalizer implements NormalizerInterface, DenormalizerInterface
{
    public function normalize(mixed $object, ?string $format = null, array $context = []): string|array
    {
        /** @var Uuid $object */
        return $object->toBase58();
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Uuid;
    }

    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Uuid
    {
        // Automatically handle incoming Base58 strings back to UUID objects
        return Uuid::fromBase58($data);
    }

    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
    {
        return is_string($data) && is_a($type, Uuid::class, true);
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            Uuid::class => true,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The Result:

  1. Database: Stores optimized 16-byte binary.
  2. API Response: {“id”: “BukQLMwS8W5g32”} (Friendly, copy-pasteable).
  3. API Request: Client sends BukQLMwS8W5g32, Symfony converts it back to the correct UUID for the database query.

Testing: Mocking the Unpredictable

Testing UUIDs is notoriously difficult because Uuid::v7() changes every millisecond. Symfony 7 provides the MockUuidFactory to solve this.

// tests/Service/TransactionTest.php

namespace App\Tests\Service;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Factory\MockUuidFactory;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV7;

class TransactionTest extends TestCase
{
    public function testItGeneratesPredictableIds(): void
    {
        // Define a sequence of IDs the factory should return
        $expectedId1 = Uuid::fromString('018e69c0-1c00-7c22-b9d6-5c4d00000001');
        $expectedId2 = Uuid::fromString('018e69c0-1c00-7c22-b9d6-5c4d00000002');

        $factory = new MockUuidFactory([$expectedId1, $expectedId2]);

        // In your real service, you would inject UuidFactory and use it
        // $service = new TransactionService($factory);

        // Simulating usage:
        $id1 = $factory->create();
        $id2 = $factory->create();

        $this->assertTrue($id1->equals($expectedId1));
        $this->assertTrue($id2->equals($expectedId2));
    }
}
Enter fullscreen mode Exit fullscreen mode

To make this work in your application code, you should inject Symfony\Component\Uid\Factory\UuidFactory into your services rather than using new UuidV7() directly if you require strict testability.

The Benchmark

To demonstrate the performance impact of UUID v7 (Monotonic) vs UUID v4 (Random), we need to measure the “Page Splitting” effect in the database. Random IDs force the database to constantly re-balance the B-Tree index, while Monotonic IDs append to the end.

This benchmark requires:

  1. PhpBench installed (composer require — dev phpbench/phpbench).
  2. A running MySQL/MariaDB instance (defined in your .env).

Create this file at benchmark/UuidInsertBench.php. This script bootstraps the Symfony Kernel to get the database connection, ensures tables exist and then measures raw insert speed.

// benchmark/UuidInsertBench.php

namespace App\Benchmark;

use App\Kernel;
use Doctrine\DBAL\Connection;
use PhpBench\Attributes as Bench;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV7;

#[Bench\BeforeMethods('setUp')]
#[Bench\AfterMethods('tearDown')]
class UuidInsertBench
{
    private Connection $connection;
    private Kernel $kernel;

    public function setUp(): void
    {
        // Bootstrap Symfony to get the DB connection
        require_once __DIR__ . '/../vendor/autoload.php';
        (new Dotenv())->bootEnv(__DIR__ . '/../.env');

        $this->kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
        $this->kernel->boot();

        $this->connection = $this->kernel->getContainer()->get('doctrine.dbal.default_connection');

        // distinct tables to isolate index behavior
        $this->createTable('bench_uuid_v4');
        $this->createTable('bench_uuid_v7');

        // Seed with initial data to ensure the index has depth
        // Fragmentation issues only appear when the table isn't empty
        $this->seedTable('bench_uuid_v4', false);
        $this->seedTable('bench_uuid_v7', true);
    }

    /**
     * Scenario: Insert into a fragmented index (Random IDs)
     */
    #[Bench\Revs(100)]
    #[Bench\Iterations(5)]
    public function benchInsertV4(): void
    {
        $uuid = Uuid::v4()->toBinary();
        $this->connection->executeStatement(
            'INSERT INTO bench_uuid_v4 (id, payload) VALUES (:id, :payload)',
            ['id' => $uuid, 'payload' => 'benchmark_payload']
        );
    }

    /**
     * Scenario: Insert into a sorted index (Monotonic IDs)
     */
    #[Bench\Revs(100)]
    #[Bench\Iterations(5)]
    public function benchInsertV7(): void
    {
        $uuid = new UuidV7(); // Explicitly V7
        $this->connection->executeStatement(
            'INSERT INTO bench_uuid_v7 (id, payload) VALUES (:id, :payload)',
            ['id' => $uuid->toBinary(), 'payload' => 'benchmark_payload']
        );
    }

    private function createTable(string $tableName): void
    {
        $sql = <<<SQL
            CREATE TABLE IF NOT EXISTS $tableName (
                id BINARY(16) NOT NULL,
                payload VARCHAR(255),
                PRIMARY KEY(id)
            ) ENGINE=InnoDB; 
SQL;
        $this->connection->executeStatement($sql);
        // Truncate to ensure clean state per run if needed, 
        // though for this test growing the table is part of the stress test.
        $this->connection->executeStatement("TRUNCATE TABLE $tableName");
    }

    private function seedTable(string $tableName, bool $isV7): void
    {
        // Pre-fill 5,000 rows to create an initial B-Tree structure
        $sql = "INSERT INTO $tableName (id, payload) VALUES (:id, 'seed_data')";

        $stmt = $this->connection->prepare($sql);

        for ($i = 0; $i < 5000; $i++) {
            $id = $isV7 ? (new UuidV7())->toBinary() : Uuid::v4()->toBinary();
            $stmt->bindValue('id', $id);
            $stmt->executeQuery();
        }
    }

    public function tearDown(): void
    {
        $this->connection->executeStatement('DROP TABLE IF EXISTS bench_uuid_v4');
        $this->connection->executeStatement('DROP TABLE IF EXISTS bench_uuid_v7');
    }
}
Enter fullscreen mode Exit fullscreen mode

Running the Benchmark:

Run the following command in your terminal:

# Allow 2GB memory for the seeding process if necessary
php -d memory_limit=2G vendor/bin/phpbench run benchmark/UuidInsertBench.php --report=default
Enter fullscreen mode Exit fullscreen mode

Interpreting Results:

You will likely see output similar to this (times will vary based on hardware):

+-----------------+-------+---------+----------+----------+---------+
| subject         | revs  | mem_peak| mode     | mean     | stdev   |
+-----------------+-------+---------+----------+----------+---------+
| benchInsertV4   | 100   | 14.20Mb | 850.12μs | 910.45μs | 55.12μs |
| benchInsertV7   | 100   | 14.20Mb | 520.40μs | 535.10μs | 12.05μs |
+-----------------+-------+---------+----------+----------+---------+
Enter fullscreen mode Exit fullscreen mode

Key Takeaway: You should observe that benchInsertV7 has a lower mean time and significantly lower standard deviation (stdev).

  • Mean: V7 is faster because the database simply appends the page to the right side of the B-Tree.
  • Stdev: V4 is erratic. Sometimes it hits a “lucky” spot, but often it triggers a “Page Split” (expensive operation to move data around), causing spikes in latency.

Conclusion

For too long, developers have treated database identifiers as a simple implementation detail — defaulting to AUTO_INCREMENT out of habit or UUID v4 out of necessity. As we’ve seen, this choice has profound downstream effects on your application’s fragmentation, pagination speed and distributed integrity.

With Symfony 7.3 and the Uid component, we no longer have to compromise. We can have the distributed uniqueness of a UUID with the insert performance of an integer (UUID v7). We can have cursor-based pagination without exposing leaky timestamps (ULID). We can communicate with our frontend in human-friendly formats (Base58) while keeping our storage strictly binary.

Switching to these modern standards is one of the highest ROI refactors you can perform on a growing Symfony application. It costs little in code but pays dividends in database health and API usability.

Let’s Build Better Systems

I write regularly about high-performance Symfony architecture, Doctrine internals and distributed patterns. If you’re tackling similar advanced challenges or have questions about implementing UUID v7 in your legacy stack:

Let’s stay in touch and keep pushing the standard.

Top comments (2)

Collapse
 
altesack profile image
Marat Latypov

Great article!
But talking about pagination, real life could be a bit more complex.
What if the user would sort records by a column other than ID?

Collapse
 
mattleads profile image
Matt Mochalkin

Greate point! This is the "Boss Level" of pagination))

The problem is that another field is not unique. If you have 50 items priced at $10.00, and your page ends at the 25th one, simply asking for WHERE price > 10.00 will skip the remaining 25 items.

To solve this, you can use the Tie-Breaker Strategy (or Tuple Comparison). You can sort by the desired column, but use the unique Uid as a fallback to enforce a strict order.

Your decoded cursor should looks like "100.50|018e69c0-..." where "100.50" - lastAmount and "018e69c0-..." - lastId.

You have to update your Repo (in prod you have to use CursorDTO with validation)

        if ($cursor) {
            [$lastAmount, $lastId] = explode('|', base64_decode($cursor));

            // Standard SQL equivalent of Tuple Comparison
            // (t.amount > :lastAmount) OR (t.amount = :lastAmount AND t.id > :lastId)
            $qb->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->gt('t.amount', ':lastAmount'),
                    $qb->expr()->andX(
                        $qb->expr()->eq('t.amount', ':lastAmount'),
                        $qb->expr()->gt('t.id', ':lastId')
                    )
                )
            )
            ->setParameter('lastAmount', $lastAmount)
            ->setParameter('lastId', Uuid::fromString($lastId)->toBinary());
        }
Enter fullscreen mode Exit fullscreen mode

For this to remain O(1) (instant) as your database grows to millions of rows, you must have a composite index in your database that matches the sort order exactly.

#[ORM\Index(name: 'idx_amount_id', columns: ['amount', 'id'])] 
Enter fullscreen mode Exit fullscreen mode

Without this composite index, the database has to scan all rows matching the amount before filtering by ID. With the index, it seeks directly to the exact tuple position in the B-Tree.