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

Domain Events Laravel Package

lingoda/domain-events

Lingoda's Laravel package for domain events helps you define typed event classes, dispatch them from domain code, and handle them through Laravel's native event system, keeping business logic decoupled, organized, and easy to test.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check first. lingoda/domain-events is a Symfony bundle with Doctrine lifecycle hooks — not a Laravel package. Your first decision, before any composer require, is whether you're adopting the dependency or the pattern. For a Laravel + Eloquent codebase the answer is almost always the pattern: three small interfaces, one trait, and one dispatcher class, landing in app/Domain/Events/. Budget half a day, not a sprint.

If you still want to read/install it:

composer require lingoda/domain-events

On a Laravel app, expect Flex/auto-discovery to do nothing. The bundle registers via config/bundles.php in Symfony; in Laravel you'd be hand-wiring a service provider around its dispatcher, and the Doctrine postFlush listener simply never fires because Eloquent isn't Doctrine. Treat installation as a research exercise in vendor/, not a deployment.

Where to look first — the source, not the README. At 8 stars and 0 dependents, src/ is the documentation. Read for exactly three things:

  1. The recording contract — the trait/interface you put on the aggregate root (record() / releaseEvents()).
  2. The dispatcher — the service that takes released events and pushes them to the framework event dispatcher.
  3. The release trigger — the Doctrine postFlush/onFlush listener that calls release after the unit of work commits.

Whiteboard the lifecycle before writing code: record in the entity → persist/flush → commit → release → dispatch. Everything else in the package is wiring.

First use case. Pick something boring and side-effect-safe: a UserRegistered event that sends a welcome email. Record it inside User::register(), flush inside the handler, let the listener mail. Get that green — including a test proving nothing dispatches on rollback — before you touch billing or anything async.

Implementation Patterns

Record inside the aggregate, never in the service. This is the entire value proposition: the entity owns its facts, the use case owns the transaction.

// domain/User/User.php
final class User extends Model
{
    use RecordsDomainEvents; // your trait, modelled on the bundle's

    public static function register(string $email): self
    {
        $user = new self(['email' => $email]);

        $user->record(new UserRegistered($email, now()->toIso8601String()));

        return $user;
    }
}

Release on the Laravel side with DB::afterCommit(). This is the direct analogue of the bundle's postFlush hook and the one piece of framework knowledge that makes the whole pattern safe:

final class RegisterUser
{
    public function __construct(private DomainEventDispatcher $events) {}

    public function __invoke(RegisterUserCommand $command): User
    {
        return DB::transaction(function () use ($command) {
            $user = User::register($command->email);
            $user->save();

            DB::afterCommit(fn () => $this->events->dispatch(...$user->releaseEvents()));

            return $user;
        });
    }
}

Calling releaseEvents() clears the buffer, so a second release is a no-op — that's your idempotency guard against double dispatch if the use case is retried.

Keep the dispatcher interface vendor-agnostic. One method, taking variadic domain events, delegating to Laravel's Event facade (or a decorated dispatcher). Nothing in app/Domain/ should reference Lingoda\ namespaces — that's what makes the swap from "vendor the bundle" to "own the trait" a non-event later.

Map event → listener, not event → job. Let Laravel's dispatcher do the routing. A listener that needs to be async implements ShouldQueue; the domain event itself stays a plain, serializable DTO (scalars and strings, not Eloquent models). If you need to enrich a queued listener, re-load the aggregate inside the listener by ID.

Test the recording, not the side effects. The package's testability claim is the pattern's best day-to-day payoff:

$user = User::register('[email protected]');
$events = $user->releaseEvents();

$this->assertCount(1, $events);
$this->assertInstanceOf(UserRegistered::class, $events[0]);

Then assert the dispatch separately with Event::fake() on the use case. Two tests, two concerns — recording logic needs no container, no database, no listeners.

Integration surface. Use domain events as the seam for: welcome/transactional mail, webhook fan-out, search-index updates, audit-log writes, and cross-bounded-context notifications. Anything that is a reaction to a state change rather than part of it. If the reaction must be transactional with the write, it isn't an event — it belongs in the use case.

Gotchas and Rules

The Doctrine hooks will not fire on Eloquent. This is the single biggest trap. If you install the bundle and assume postFlush maps to Laravel's transaction callbacks, you'll write code that records events and silently never dispatches them. Verify with a deliberate dd() in the release path on day one.

No outbox means no durability guarantee. Events are buffered in memory and released after commit. A fatal error or worker kill between COMMIT and dispatch() loses them with no trace. If "never lose this event" is a requirement, you need an outbox table (persist events in the same transaction, relay them after) — this package does not provide one, and neither does the pattern out of the box.

DB::afterCommit() only fires on the outermost transaction. Nested DB::transaction() calls collapse; callbacks registered inside a savepoint still wait for the real commit — which is what you want, but it surprises people who expect per-transaction granularity. Conversely, if the outer transaction is never committed (e.g. a test that wraps everything), your events never dispatch. In tests, use DatabaseTransactions carefully or call the release path explicitly.

A throwing listener after commit corrupts nothing but hides everything. The write succeeded; downstream did not. Wrap the dispatch loop in a try/catch that logs and reports to your error tracker — never catch (Throwable) {}. Consider a dead-letter table or a failed-events job for async listeners.

Queue + rollback ordering. If you dispatch a queued listener, the job can execute before the transaction's changes are visible to another connection — a classic race. afterCommit solves this only if you dispatch inside it; dispatching inside the transaction and trusting afterCommit: true on the job is a different, subtler fix. Pick one, document it, and pin it with a test.

Long-running workers accumulate buffers. In Octane or a queue worker handling many aggregates, an aggregate that records but is never released holds its events for the life of the process. Always release in a finally or after the commit callback; consider a ResetInterface-style reset on the recorder between requests.

Serializable events only. No Eloquent models, no closures, no Carbon instances with timezone context you care about. Strings, ints, arrays, and value-object DTOs with explicit __construct hydration. This bites you the first time a listener is moved to a queue.

Version your event names now. UserRegistered → UserRegisteredV2 when the payload changes. With zero ecosystem behind this package, you have no tooling for schema migration; naming discipline is your only mechanism.

Extension points worth knowing. The dispatcher is the seam: decorate it to add correlation IDs, structured logging, or a "dry-run" mode that records without dispatching. The recording trait is the other seam: add peekEvents() for assertions without clearing the buffer, and a resetEvents() for worker hygiene.

Debugging checklist. Use Event::fake() to confirm if dispatch happened; Event::assertDispatched() to confirm what; Telescope's Events tab to confirm when relative to your query log — if the event appears before the COMMIT in the log, your afterCommit wiring is wrong.

Pin it or clone it. Zero dependents and a single corporate maintainer means every framework major becomes your fork-and-patch exercise. If you do adopt the dependency, pin the exact version and vendor the src/ directory with a comment explaining provenance — the code is small enough that owning it costs less than tracking it.

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.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle