Swap Your ID Strategy Without Touching Business Code
Most PHP projects hardcode their ID generation strategy. Calls to Uuid::uuid4() are scattered across handlers, entities, and tests. When you need to switch to UUID v7 for chronological sorting or ULID for shorter URLs, you end up modifying dozens of files - and hoping you found them all.
The problem
ID generation seems like a solved problem until your requirements change. You started with UUID v4 because it was the default in ramsey/uuid. Six months later, your DBA asks why the primary key index is fragmented - UUID v4 is random, so inserts scatter across the B-tree. You want UUID v7 for time-based ordering, but Uuid::uuid4() is called directly in 30 handlers.
The root cause is coupling. Your business code depends on a specific generation strategy instead of depending on what it actually needs: something that produces an ID.
The mechanism
Phexium splits ID generation into two interfaces at two architectural levels.
At the domain level, IdInterface defines what an ID can do:
namespace Phexium\Domain\Id;
interface IdInterface
{
public function __toString(): string;
public static function from(int|string $id): self;
public function getValue(): int|string;
public function equals(IdInterface $other): bool;
}
At the plugin level, IdGeneratorInterface defines how IDs are created:
namespace Phexium\Plugin\IdGenerator\Port;
use Phexium\Domain\Id\IdInterface;
interface IdGeneratorInterface
{
public function generate(): IdInterface;
public function from(int|string $id): IdInterface;
}
Four strategies implement this contract. Each one is a pair: a Value Object (final readonly) and a generator adapter.
UuidV4Generator produces random UUIDs. Good for distributed systems where collision probability must stay negligible.
final readonly class UuidV4Generator implements IdGeneratorInterface
{
public function generate(): UuidV4
{
$uuid = Uuid::uuid4();
return UuidV4::from($uuid->toString());
}
}
UuidV7Generator produces time-based UUIDs. They sort chronologically, which keeps database indexes compact.
UlidGenerator encodes a UUID v7 into 26 characters of Base32 Crockford. Same time-ordering benefits, shorter representation for URLs.
final readonly class UlidGenerator implements IdGeneratorInterface
{
public function generate(): Ulid
{
$uuid = Uuid::uuid7();
return Ulid::from($uuid->toString());
}
}
TimestampIdGenerator produces integer IDs from microtime(). No external dependency, no string parsing - useful for integration tests where readability and speed matter more than distribution safety.
A handler never knows which strategy is active:
final readonly class CreateBookHandler implements CommandHandlerInterface
{
public function __construct(
private BookRepository $bookRepository,
private EventBusInterface $eventBus,
private ClockInterface $clock,
private IdGeneratorInterface $idGenerator,
) {}
public function __invoke(CreateBookCommand $command): void
{
$this->eventBus->dispatch(
new BookCreatedEvent(
$this->idGenerator->generate(),
$this->clock->now(),
$book
)
);
}
}
Switching the entire application from UUID v4 to ULID is one line in the DI container:
Trade-offs
This abstraction has a cost. You lose the ability to call strategy-specific methods on IDs in your domain code. If you need UUID-specific operations (extracting the timestamp from a v7, for instance), you must either add that method to IdInterface or cast - and casting defeats the purpose.
The from() method on each generator rejects integers for UUID-based strategies and throws a LogicException. If you switch from TimestampIdGenerator to UuidV4Generator, existing integer IDs in your database will not reconstruct. A migration strategy is your responsibility.
There is also a subtle dependency in UlidGenerator: it generates a UUID v7 internally and converts it to Base32. If ramsey/uuid changes its v7 implementation, your ULID output changes too.
What this enables
You can use TimestampIdGenerator in tests for readable, fast IDs and UuidV7Generator in production for proper distribution and ordering - same handlers, same domain code, different container configuration per environment.
Adding a fifth strategy (Snowflake IDs, KSUID, NanoID) means implementing IdInterface and IdGeneratorInterface. No existing code changes. This is the Open/Closed Principle applied to identity generation.
Your domain stays ignorant of the ID format. That ignorance is the feature.