Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ddd Extensions Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. AbstractId / AbstractUuid — the identifier value-object base (backed by ramsey/uuid), including equals().
  2. AbstractEnum — string-constant enum value objects with AbstractEnumType for Doctrine.
  3. AbstractString — generic string wrapper for emails, names, slugs.
  4. AbstractEntity / aggregate-root base — identity equality and (depending on version) domain-event recording.
  5. Any *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'; }
}

Implementation Patterns

Typed IDs

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.

Enums

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.

String value objects with invariants

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.

Aggregate roots and domain events

If your version includes the aggregate-root base, the workflow is:

  1. Aggregate raises events: $this->raise(new OrderPaid($this->id));
  2. A repository wrapper (DoctrineRepository base, or your own) collects releaseEvents() after flush().
  3. A 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.

Repository base class

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.

Testing

  • Unit-test value objects directly — they have no framework dependencies.
  • For integration tests, register the same dbal.types in config/packages/test/doctrine.yaml and use SQLite in-memory; the custom types are driver-agnostic.
  • Assert on value objects, not strings: self::assertTrue($order->id()->equals($expectedId)).

Gotchas and Tips

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.
  • Doctrine embeddables — avoid custom types entirely for simple VOs, at the cost of more verbose mapping.
  • 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package