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 Doctrine Bridge Laravel Package

alexandrebulete/ddd-doctrine-bridge

Doctrine ORM bridge for DDD Foundation, providing repository and paginator implementations plus custom DBAL types for value objects. Composable traits give full CRUD or read-only/write-only repositories. Pure PHP, framework-agnostic, with an optional Symfony bundle.

View on GitHub
Deep Wiki
Context7

Getting Started

Install both halves. The bridge is useless without ddd-foundation (it supplies RecordsEvents and EventDispatcherInterface):

composer require alexandrebulete/ddd-doctrine-bridge alexandrebulete/ddd-foundation
composer require doctrine/orm doctrine/dbal   # Laravel does not ship these

Pin ^1.1.2 or later — 1.1.1 and 1.1.2 are bug fixes for duplicated eventDispatcher usage and a trait property collision you will hit the moment you compose traits in a Laravel app.

Read these three files first, in this order — they are each about a screen long:

  1. src/Capability/AsCrudable.php — shows exactly which atomic traits compose the whole surface. Everything else is mechanical.
  2. src/DoctrineRepository.php — the constructor contract: (EntityManagerInterface $em, string $entityClass, string $alias).
  3. src/Type/GuidType.php + src/Type/Convertor/AsIdentifierConvertor.php — the only genuinely non-trivial code in the package.

There is no Laravel service provider. You wire EntityManagerInterface yourself. Put it in a dedicated provider so it is obvious this is a second persistence stack:

// app/Providers/DoctrineServiceProvider.php
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMSetup;

public function register(): void
{
    $this->app->scoped(EntityManagerInterface::class, function ($app) {
        $config = ORMSetup::createAttributeMetadataConfiguration(
            paths: [app_path('Domain')],
            isDevMode: $app->environment('local'),
        );

        // Must run BEFORE the EntityManager/Connection is created.
        Type::addType('post_id', \App\Post\Infrastructure\Type\PostIdType::class);

        $db = config('database.connections.mysql');

        $connection = DriverManager::getConnection([
            'driver'   => 'pdo_mysql',
            'host'     => $db['host'],
            'port'     => $db['port'],
            'dbname'   => $db['database'],
            'user'     => $db['username'],
            'password' => $db['password'],
        ], $config);

        return new EntityManager($connection, $config);
    });
}

Use scoped, not singleton — see Gotchas.

First use case: a read-only reference repository. Smallest blast radius, no writes, no events, and it proves the wiring end to end:

// app/Country/Infrastructure/Persistence/DoctrineCountryRepository.php
use AlexandreBulete\DddDoctrineBridge\Capability\AsReadable;
use AlexandreBulete\DddDoctrineBridge\DoctrineRepository;

final class DoctrineCountryRepository extends DoctrineRepository implements CountryRepositoryInterface
{
    use AsReadable;

    public function __construct(EntityManagerInterface $em)
    {
        parent::__construct($em, Country::class, 'country');
    }

    public function findById(CountryId $id): ?Country
    {
        return $this->findEntityById(Country::class, $id->value());
    }

    public function findAll(): array
    {
        return $this->findAllEntities();
    }
}

Bind it in a provider: $this->app->bind(CountryRepositoryInterface::class, DoctrineCountryRepository::class);. Controllers and actions never see EntityManagerInterface.

Implementation Patterns

Interface in the domain, Doctrine in infrastructure, always. The package ships no repository interfaces — that is intentional, and it is also your exit plan. Define PostRepositoryInterface in your domain layer with your own method names (save, delete, findById), implement it with the traits, and never let persistAndFlush leak upward.

Climb the capability ladder instead of starting at AsCrudable. The traits compose in one direction:

Stage Trait What you get
Read AsReadable findEntityById, findAllEntities
Read + create AsReadable + CanPersist + DispatchesDomainEvents no delete on the class at all
Full AsCrudable read + persist + remove + events

The audit-log shape is the one worth stealing on day one: AsReadable + CanPersist compiles an append-only repository. Deletion is impossible rather than merely discouraged — that is a real design win over a fat abstract base class, and it is checkable in code review.

Domain events: default to AFTER, and write it down. persistAndFlush takes dispatchEvents: true plus a WhenDispatchDomainEventsEnum. BEFORE fires events for transactions that may still roll back — phantom "OrderPlaced" notifications. Standardise on:

public function save(Post $post): void
{
    $this->persistAndFlush(
        entity: $post,
        dispatchEvents: true,
        whenDispatchEvents: WhenDispatchDomainEventsEnum::AFTER,
    );
}

The aggregate must use RecordsEvents (from ddd-foundation) and record inside its own methods — never from the repository or a controller.

Value object types: one subclass per VO, mapping via the column attribute.

final class PostIdType extends GuidType
{
    protected string $name = 'post_id';
    protected string $voClass = PostId::class;
}

// Entity mapping
#[ORM\Id]
#[ORM\Column(type: 'post_id')]
private PostId $id;

GuidType is for VOs wrapping an AbstractUid/RFC 4122 identifier. If your VO wraps a plain string that is not a UUID, use VarcharType (or TextType for CLOB) with AsStringConvertor — otherwise the toRfc4122() conversion path is a category error, not a bug fix.

Keep the ID conversion inside the repository. findEntityById(Post::class, $id->toRfc4122()) — the toRfc4122() / ->value() call belongs in the adapter. If it appears in a controller, your domain is leaking Doctrine's scalar-shaped identity into the HTTP layer.

Pagination is yours to adapt. DoctrinePaginator exists, but there is no specification/query-object layer in this package. Build the QueryBuilder (filters, ordering, joins) inside the repository method and hand the query to the paginator, returning your domain's own paginator DTO. Do not let callers pass a QueryBuilder across the boundary.

Test repositories without booting Laravel. They are plain PHP. Construct an EntityManager over in-memory SQLite, run SchemaTool::createSchema(), and assert against real SQL. This is faster than a Laravel feature test and actually exercises the DBAL types.

Isolate Doctrine aggregates by table. Do not point an aggregate at a table Eloquent also writes. Two ORMs, two identity maps, two flush lifecycles, no shared transaction.

Gotchas and Tips

  • Type registration differs by DBAL major. Type::addType() is the DBAL 3 API; DBAL 4 moved type registration. Symptom either way is the same: Unknown column type "post_id" requested. Registration must also happen before the connection is constructed — register in the provider closure body, above DriverManager::getConnection().
  • scoped, not singleton, for the EntityManager. Under Octane a long-lived EntityManager accumulates a stale identity map and leaks memory across requests. Bind as scoped, and if you are on a classic FPM setup still call $em->clear() at the end of any queued job.
  • Laravel cannot see Doctrine queries. DB::listen, Telescope, and DB::enableQueryLog() all observe Eloquent's connection only. Your Doctrine queries are invisible in the standard Laravel debugging toolchain. Wire a DBAL middleware/logger explicitly, or you will debug N+1s blind.
  • env() outside config/ returns null after config:cache. The provider above deliberately reads config('database.connections.mysql'), not env('DB_HOST'). Getting this wrong passes as working locally and fails on the first cached deploy.
  • Do not add DispatchesDomainEvents next to AsMutable. AsMutable already includes it. Version 1.1.1 exists precisely because the dispatcher was being pulled in twice; composing it manually re-opens that door (fatal trait method collisions on some PHP versions).
  • Events may be dispatched more than once if the aggregate never clears them. After an AFTER flush the listeners run; if RecordsEvents is not drained at that point, the next persistAndFlush on the same instance replays the stale events. Assert releaseEvents/pullEvents is called, and cover it with a test that saves twice.
  • AFTER is after commit, so a throwing listener cannot roll back. Any listener exception now leaves committed data plus a partially-notified world. Keep listeners thin, queue side effects, and treat the AFTER choice as a delivery-guarantee decision rather than a code-style preference.
  • parent::__construct() needs three arguments. The $entityClass and $alias are easy to forget; the failure is a confusing runtime error deep in a query, not a constructor type error.
  • Two migration toolchains is a real fork in the road. Decide up front whether Laravel migrations or Doctrine's SchemaTool owns the schema. Running both against the same database produces drift that neither tool reports. Most Laravel shops should keep Artisan migrations authoritative and use Doctrine purely as a read/write mapper.
  • Trait version floor matters. Changelog 1.1.2 removes a property declaration from a trait "to avoid conflict" — this collides directly with any $eventDispatcher property you declare on a repository class. Do not pin below it.
  • No specifications, no custom hydrators, no collection mapping, no shipped test suite. Everything beyond the four operations and three base types you write yourself. Budget for it; do not assume the README's omission is an oversight.
  • Vendor risk is concrete. One star, zero dependents, a single maintainer, and a hard dependency on ddd-foundation. The trait layers are an afternoon to reproduce; the GuidType/convertor family and the flush-ordering logic are the parts worth keeping. Wrap everything behind your own repository interfaces so a fork — if it ever comes to that — is a contained change rather than a migration.
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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo