skrepr/id-type
Skrepr ID Type adds type-safe ID value objects to Laravel Eloquent models, with a custom cast for UUIDs, ULIDs, and other identifiers so you can validate, serialize, and use IDs consistently across your application. Lightweight and easy to configure.
skrepr/id-type solves one specific problem: replacing primitive int/string identifiers on Doctrine entities with strongly-typed ID value objects (UserId, OrderId, …) without hand-writing a DBAL type, hydrator, or serializer for every one of them.
Minimal path:
composer require skrepr/id-typefinal class UserId extends AbstractIdType {}
Type::addType() / the doctrine config block (Laravel's Eloquent has no equivalent — this package is for the Doctrine ORM side of a Laravel codebase, or for a Symfony/standalone service).#[ORM\Id]
#[ORM\Column(type: UserId::class)]
private UserId $id;
Where to look first: the abstract ID base class (that's where generate(), fromString(), and __toString() semantics are defined), then the registered DBAL type — those two files answer almost every question you'll have. Check the package's own test suite for the canonical entity mapping example rather than guessing at attribute syntax.
First real use case: swap the id of a single aggregate root from int to a typed ID, generate it in the constructor ($this->id = UserId::generate();), and confirm a round-trip (persist → clear → find) returns an instance, not a raw string. Do this on one entity before touching the rest of the model.
One ID class per aggregate, not per entity. UserId, OrderId, InvoiceId — never a shared GenericId. The whole value of the package is that a method signature find(OrderId $id) cannot be called with a UserId by accident. The moment you share a base class across aggregates you lose that.
Generate at construction, never at persistence. Assign in the entity constructor (or a named factory) so the ID exists before flush(). This keeps the entity valid in memory, makes events dispatched pre-persist referenceable, and avoids the "when is the ID null?" branch that spreads through your codebase.
Type-hint IDs in repositories and services. Use find(OrderId $id): ?Order on your repository rather than find($id). This is where the package pays for itself — static analysis catches ID mix-ups at the call site instead of at runtime after a bad join.
EntityValueResolver for controller input. Where you use Symfony's EntityValueResolver (or the Laravel equivalent via a Doctrine bridge), typed IDs let route parameters convert straight into entities without manual find() plumbing in every controller action. This is exactly the path 1.3.2 tightened for Doctrine ORM 3 — see Gotchas.
Serialization boundary. Expose (string) $id in API resources / JSON responses, and accept a string on the way in, converting at the edge (UserId::fromString($request->input('id'))) so the ID object never leaks into request validation rules or Blade templates. Keep fromString() calls at the boundary and let the domain work with objects.
Testing seam. Because IDs are constructible from a known string, tests can assert on fixed IDs (OrderId::fromString('...')) instead of capturing generated values. Build a small helper/factory for deterministic IDs; it removes most of the friction of testing entities keyed by generated identifiers.
Migration discipline. All ID columns should share one physical representation (char(36) for UUID-style, or binary(16) if the package is configured that way). Decide once, document it, and add new entities to that convention — mixed representations across tables is painful to unwind later.
1.3.2 specifically: EntityValueResolver + doctrine/orm:^3. This release is a compatibility fix, not a behaviour change. If you pinned doctrine/orm to ^2.x you are unaffected. If you are on ORM 3 and were seeing entity resolution fail or misbehave in controllers (typically the resolver not converting a route parameter into the expected entity, or falling back to a find() with the wrong type), 1.3.2 is the version to be on. Upgrade, then re-run your functional/controller tests for any action that relies on implicit entity resolution — that's the surface area this touched.
Type registration must match the #[ORM\Column(type: ...)] string exactly. The most common failure mode is registering the DBAL type under the short name user_id but annotating the column with the FQCN (or vice versa). Symptoms are confusing: Doctrine may silently treat the column as string and hydrate a string rather than throwing. If hydration returns a raw string instead of your ID object, check registration first.
Equality is not object identity. Two UserId instances built from the same UUID are not ===. Compare with ->equals() if the base provides it, or (string) $a === (string) $b. This bites in in_array() (without strict flag), array_unique, and Doctrine's identity map assertions in tests.
Doctrine's identity map keys on the DBAL-converted value. If your conversion is inconsistent (e.g. casing differences between fromString() and what the DB returns), you can end up with two in-memory instances of the same row. Normalise on the way in and out.
Missing ID on a new entity fails at flush, not at construction. If you forget to assign in the constructor, PHP will throw Typed property must not be accessed before initialization — usually from deep inside Doctrine's UnitOfWork, which obscures the cause. Initialise eagerly in the constructor.
Keep ID classes final and immutable. No setters, no public mutable state. The package's extension points are the DBAL type and the base class — subclass the base for a new ID, don't try to make an existing ID class configurable after the fact.
Schema length matters. A 36-char UUID in a VARCHAR(32) column truncates silently on some drivers. Verify the migration column definition matches the package's expected string length before you have production data.
Debugging tip: when an entity mysteriously won't persist or is treated as new, dump $entity->getId() type — if it's string, your type conversion isn't wired and Doctrine is comparing a fresh string ID against a persisted object ID, so it always sees a new entity.
How can I help you explore Laravel packages today?