fusonic/ddd-extensions
A lightweight PHP package of Domain-Driven Design building blocks, offering reusable base classes and extensions for entities, value objects, aggregates, and domain events. Helps keep Laravel and plain PHP projects structured around explicit domain models.
Install and register
composer require fusonic/ddd-extensions
The package ships a Symfony bundle (FusonicDddExtensionsBundle). On Symfony Flex it is usually auto-registered; otherwise add it to config/bundles.php. Since most of the package is plain PHP base classes, the bundle registration is mostly relevant if you use the provided Doctrine types/compiler passes — check src/ for the exact bundle class name in the installed version, as this is a low-traffic package where the README may lag behind the code.
Register Doctrine types. This is the single most important first step. The package's value objects are only persisted if you map them to custom DBAL types in config/packages/doctrine.yaml:
doctrine:
dbal:
types:
order_id: App\Infrastructure\Doctrine\OrderIdType
order_status: App\Infrastructure\Doctrine\OrderStatusType
Where to look first
AbstractId / AbstractUuid — the identifier value-object base (backed by ramsey/uuid), including equals().AbstractEnum — string-constant enum value objects with AbstractEnumType for Doctrine.AbstractString — generic string wrapper for emails, names, slugs.AbstractEntity / aggregate-root base — identity equality and (depending on version) domain-event recording.*Type classes — these are the doctrine/dbal Type subclasses you extend per project.First use case (the "hello world")
Replace a bare string $id on one aggregate with a typed ID, and one status string with an enum value object. Nothing else. You get type safety across the domain, a single place to validate the ID format, and a migration path for the rest of the model without a big-bang rewrite.
final class OrderId extends AbstractUuid {}
final class OrderIdType extends AbstractIdType
{
public function getName(): string { return 'order_id'; }
}
final class OrderId extends AbstractUuid
{
// optionally add a named constructor for readability
public static function generate(): self { return self::generateUuid(); }
}
#[ORM\Entity]
final class Order
{
#[ORM\Id]
#[ORM\Column(type: 'order_id')]
private OrderId $id;
public function __construct(OrderId $id) { $this->id = $id; }
public function id(): OrderId { return $this->id; }
}
The pattern scales well: one *Id class + one *Type class per aggregate, all 10–15 lines. Repositories can then expose find(OrderId $id): ?Order and never see a raw string.
final class OrderStatus extends AbstractEnum
{
public const OPEN = 'open';
public const PAID = 'paid';
public const CANCELLED = 'cancelled';
}
final class OrderStatusType extends AbstractEnumType
{
public function getName(): string { return 'order_status'; }
protected function getEnumClassName(): string { return OrderStatus::class; }
}
Because the enum subclasses a base class, you can put transition logic (canTransitionTo(), isFinal()) directly on the value object instead of a service — this is the main reason to prefer it over native PHP enums in this codebase.
final class Email extends AbstractString
{
protected function validate(): void
{
if (filter_var($this->value, FILTER_VALIDATE_EMAIL) === false) {
throw new \InvalidArgumentException("Invalid email: {$this->value}");
}
}
}
Put validation in validate() (or the constructor hook your version provides) so it runs on hydration and on new Email(...). This is where the package pays for itself: invariants can't be bypassed by Doctrine loading a row.
If your version includes the aggregate-root base, the workflow is:
$this->raise(new OrderPaid($this->id));DoctrineRepository base, or your own) collects releaseEvents() after flush().DomainEventPublisher/dispatcher fans them out to Symfony's EventDispatcher or Messenger.Keep the release-and-dispatch step in one place — a repository decorator or a postFlush listener — and never dispatch inside the aggregate.
Extend the provided repository base so every aggregate gets consistent save(), getById() (throws a domain exception instead of returning null) and event release. This is the highest-leverage integration point in the package: one base class, all aggregates consistent.
dbal.types in config/packages/test/doctrine.yaml and use SQLite in-memory; the custom types are driver-agnostic.self::assertTrue($order->id()->equals($expectedId)).Missing type registration is the #1 failure. If you forget a dbal.types entry you get Unknown column type "order_id" requested at schema tool / hydration time, often only when running doctrine:schema:validate or in a functional test. Add a smoke test that boots the kernel and runs doctrine:schema:validate.
Custom ID types and find(). EntityManager::find(Order::class, 'uuid-string') passes a raw string into your type's convertToDatabaseValue, which expects an OrderId. Either always pass the value object, or add a repository method that wraps: find(OrderId $id). Also be aware the identity map is keyed by the DB value, so mixing raw strings and value objects can produce surprising "already managed" behaviour.
SQL declaration mismatches cause phantom migrations. If AbstractIdType::getSQLDeclaration() returns CHAR(36) but your existing column is VARCHAR(36), Doctrine's schema diff will keep proposing a change. Align the type's declaration with the existing column, or generate a one-off migration and then stop running doctrine:schema:update.
Serialization needs adapters. Value objects won't serialize cleanly with the Symfony Serializer or API Platform. Add normalizers (OrderIdNormalizer, one generic enum normalizer) rather than exposing __toString everywhere.
Don't mix with native PHP enums. The AbstractEnum pattern predates BackedEnum. If you need from()/tryFrom(), form EnumType integration, or validator Choice constraints, write a small adapter or migrate to native enums + a custom DBAL type first — mixing both in one model is confusing.
Domain events dispatch timing. Dispatch after flush() succeeds; dispatching before means consumers see entities that may never be persisted. Guard against double dispatch by clearing the event list in releaseEvents(), and add a re-entrancy guard if your dispatcher can trigger another flush.
Vendor risk is real. One star, zero dependents, low activity. The upside is MIT licensing: vendor the handful of classes you actually use (AbstractUuid, AbstractEnum, AbstractString, the two Type bases) into src/Shared/Domain/ and drop the dependency. That removes upgrade risk while keeping the ergonomics, and it's a couple hundred lines of code. Do this if the project is long-lived or if the package is pinned to a Doctrine/Symfony version you'll outgrow.
Alternatives to evaluate before adopting:
symfony/uid + native enums + hand-written DBAL types — covers 80% of this package with first-party support.patchlevel/event-sourcing or symfony/messenger directly if domain events are the main draw.Extension points worth knowing: AbstractEnumType::getEnumClassName(), AbstractIdType::getName() (the DBAL type name), AbstractEntity::equals() (override if you have composite identity), and any validate() hook on the string/enum base classes. These are the seams you'll touch repeatedly, so wrap them in small project-local base classes (AppId, AppEnum, AppEnumType) on day one — it makes future package removal a find-and-replace.
How can I help you explore Laravel packages today?