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

Tactician Doctrine Laravel Package

league/tactician-doctrine

Integrates the Tactician command bus with Doctrine via middleware that wraps command handlers in database transactions. It manages EntityManager/DBAL connections, helping keep command execution atomic and simplifying transactional command handling in PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require league/tactician-doctrine

You also need league/tactician and either doctrine/orm (for the ORM plugins) or doctrine/dbal (for the connection plugins). Note the last release was January 2022 — run composer why-not league/tactician-doctrine against your ORM/DBAL versions before you plan around it.

Where to look first. The whole package is a handful of classes. Read, in this order:

  1. src/ORM/TransactionMiddleware.php — the one you actually want
  2. src/ORM/RollbackOnlyMiddleware.php
  3. src/DBAL/TransactionMiddleware.php
  4. src/DBAL/PingConnectionMiddleware.php and CloseConnectionMiddleware.php

Fifteen minutes of reading tells you everything; there is no configuration, no service provider, no YAML.

First use case. Wrap the command bus so a single command is atomic — "create an order, its lines, and its invoice record, or none of it":

use League\Tactician\CommandBus;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Doctrine\ORM\TransactionMiddleware;

$bus = new CommandBus([
    new TransactionMiddleware($entityManager),
    $handlerMiddleware, // locates and invokes the handler
]);

$bus->handle(new PlaceOrder($orderId, $lines));

In Laravel, register that bus as a singleton in a service provider and inject CommandBus wherever you'd otherwise inject a service.

final class PlaceOrderHandler
{
    public function __construct(private EntityManagerInterface $em) {}

    public function handle(PlaceOrder $command): void
    {
        $order = new Order($command->orderId);
        $this->em->persist($order);
        $this->em->flush();   // <-- the middleware does NOT flush for you
    }
}

Implementation Patterns

One transaction per command (the default). Put TransactionMiddleware first in the chain. A Throwable from anywhere downstream rolls back; returning normally commits. This is the 90% case and the reason the package exists.

Top-level owner / nested dispatch. When a command handler dispatches other commands, don't put a second TransactionMiddleware on the inner bus. Let the outermost command own the transaction. If you can't avoid nesting, turn on savepoints on the DBAL connection (setNestTransactionsWithSavepoints(true) on DBAL 2) so an inner failure can be rolled back without poisoning the outer unit of work.

Soft failure with RollbackOnlyMiddleware. Place it immediately inside TransactionMiddleware. A handler can then mark the transaction rollback-only and return normally (useful when you want to record the failure but still discard the partial writes) instead of throwing to force a rollback.

Long-running workers (queue consumers, Swoole, ReactPHP). Order matters — outermost runs first and last:

$bus = new CommandBus([
    new CloseConnectionMiddleware($connection),   // outermost: closes after commit
    new PingConnectionMiddleware($connection),    // reconnect before we start
    new TransactionMiddleware($entityManager),
    new RollbackOnlyMiddleware($entityManager),
    $handlerMiddleware,
]);

PingConnectionMiddleware must sit outside the transaction (you can't recover a dead connection mid-transaction), and CloseConnectionMiddleware must be outside the transaction middleware or it will close the connection before the commit runs.

Domain events after commit. The middleware has no afterCommit hook. Collect events in the aggregate, then dispatch them in the caller after $bus->handle() returns, or write a small middleware that runs next, then dispatches on success:

final class DispatchEventsMiddleware implements Middleware
{
    public function execute($command, callable $next)
    {
        $result = $next($command);
        $this->dispatcher->dispatchBuffered(); // only reached if commit succeeded
        return $result;
    }
}

Testing. Build a bus per test against an in-memory SQLite EntityManager, and assert on both paths: handle a command and assert rows exist; handle a command whose handler throws and assert nothing persisted. Do not wrap these tests in a beginTransaction/rollBack fixture (see Gotchas).

Gotchas and Tips

No flush, no save. The middleware only does beginTransaction / commit / rollback. If your handler forgets flush(), changes linger in the identity map and get written by the next command's commit — a genuinely nasty cross-command write that's hard to debug. Always flush explicitly at the end of the handler.

Nested rollbacks poison everything. Without savepoints, a nested rollBack() marks the whole DBAL transaction rollback-only, and every later flush() throws "transaction is marked for rollback only". This bites hardest in PHPUnit: if your test case wraps each test in a transaction and a command throws, the test's transaction is now poisoned and subsequent assertions/flushes fail confusingly. Either don't wrap transaction-middleware tests in a transaction, or enable savepoint nesting.

The EntityManager closes after a failed flush. Doctrine calls EntityManager::close() when a flush throws. The middleware rolls back correctly, but the EM stays closed and unusable. In a worker you must reset the manager (ManagerRegistry::resetManager()) and, at minimum, clear() between commands to stop the identity map growing unbounded.

Anything after $bus->handle() is outside the transaction. Emails, HTTP calls, event dispatch, queue pushes — all post-commit, which is usually what you want but is easy to forget when you "just add one line" inside the handler.

Throwable means rollback — including exceptions you consider benign. Do validation and authorization before you enter the bus, or you'll roll back work you meant to keep.

Version compatibility is the real risk. Released 2022-01-17, 58 stars, near-zero dependents. PingConnectionMiddleware depends on Connection::ping(), which DBAL 3 deprecated/removed — test that specific middleware on your DBAL version rather than assuming. If Composer fights you on ORM 3 / DBAL 3+, the pragmatic answer is to copy the three relevant classes into app/Support/CommandBus/ and own them; they are ~40 lines each and the middleware interface is execute($command, callable $next).

Don't mix it with Laravel's DB layer blindly. Doctrine's DBAL Connection and Illuminate\Database\Connection are distinct wrappers. If they don't share the same underlying connection object, Doctrine knows nothing about DB::transaction() and vice versa — you'll get writes committing outside the transaction you thought you were in. Pick one transaction boundary per request.

Extension point. Because the middleware contract is trivial, prefer composing your own (Transaction + Flush, Transaction + AfterCommitEvents) over contorting middleware ordering — ordering bugs here surface as silent data loss, not exceptions.

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.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel