Skip to content

(feat) deliver domain notifications only after a transaction commits - #366

Merged
techmahedy merged 2 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Oct 15, 2026
Merged

techmahedy merged 2 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

@techmahedy techmahedy commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Problem - Solution of this issue

Reacting to a change from inside a database transaction can announce work that is later rolled back: a receipt for a payment that never happened, an index refresh for a row that no longer exists. The framework had no dispatcher at all (#[Hook] is model lifecycle only and fires immediately), so applications had to hand-roll this around DB::transaction().

What this adds

A minimal, synchronous signal bus whose signals can wait for the surrounding transaction to settle, meaning the outermost transaction committed.

#[DeliverOnSettle]
class InvoicePaid
{
    public function __construct(public readonly int $invoiceId) {}
}

Signal::on(InvoicePaid::class, SendReceipt::class);

DB::transaction(function () use ($invoice) {
    $invoice->markPaid();

    Signal::emit(new InvoicePaid($invoice->id));   // held until commit, dropped on rollback
});
Piece Purpose
Signal facade, signal() helper, 'signal' binding on, forget, hasListeners, emit, emitNow, emitOnSettle, reportFailuresUsing
#[DeliverOnSettle(connection: ...)] Marks a signal class as commit-bound, optionally to a named connection
php pool make:signal {name} {--settle} {--connection=} Scaffolds a signal class in src/Signals

The naming is deliberately Doppar's own (signals, settle, emit) rather than the vocabulary of other frameworks.

How it works

All transaction state already lives in Database, so the change is there and the bus stays small.

  • Pending signals are held per connection and per nesting level (Database::$pendingSignals).
  • Commit of an inner level: its signals move up to the parent, keeping chronological order.
  • Commit of the outermost level: signals are taken out of the buffer first, then delivered after the counter is decremented, so a listener sees a closed transaction and may open its own.
  • Rollback (including a savepoint rollback): the level's signals and those of any inner level that had already committed into it are discarded. This happens before the PDO call, so a rollback that itself fails cannot leak signals.
  • transaction($callback, $attempts) retries therefore drop the failed attempt's signals.
  • disconnect() and cleanupAllConnections() clear pending signals along with the transaction counters.
  • Each pending entry stores the bus that staged it, so delivery never depends on a container lookup.

Listener failures (documented behaviour)

Delivery A listener throws Remaining listeners
Immediate Propagates to the caller Do not run
After a commit Not propagated, reported Still run

After a commit the data is permanent, so letting an exception escape would make a successful commit look failed and skip other pending signals. Reporting goes to Signal::reportFailuresUsing(), else the log service, else a best-effort error_log(). A throwing reporter or logger also falls back to error_log() and can never break the commit. This mirrors the fallback used for defer() cleanup failures.

Listeners and ordering

  • Higher priority runs first; equal priorities keep registration order.
  • A listener registered for a parent class or interface receives subclasses and implementers.
  • Listeners may be callables, [class, method], [object, method] or invokable class names (built through the container per delivery). Invalid listeners are rejected at registration.

Out of scope

  • Transactions opened directly on a raw PDO object are not tracked.
  • A signal waits for one connection's transaction.

Tests

tests/Signals/ (37 tests) covers: priorities, equal priorities, parent and interface listeners, listener forms and validation, immediate propagation, commit delivers once, rollback discards, nested commit and rollback at several depths, chronological order across levels, inner-failure-caught-by-outer, retries, two connections committing and rolling back independently, a signal bound to a connection with no transaction, emitOnSettle / emitNow, failing listener with a reporter, with no reporter and with a throwing reporter, listeners opening their own transaction, disconnect, and two buses.

tests/Console/CommandBehaviorCoverageTest.php gains four tests for make:signal: plain, --settle, --connection implying settle, refusing to overwrite, and rejecting unsafe connection names (the value is written into generated source). Each generated file is linted with php -l.

Docs

Documented in the docs repo (4.x): new signals.md page, facade table, helper entry, and a pointer from the transactions section of entity-builder.md. docs link

Checklist

  • Tests have been added or updated
  • Documentation has been updated if necessary
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy techmahedy added the feat new feature label Oct 14, 2026
@abdulmajidcse

abdulmajidcse commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

@techmahedy I'll do it soon

@techmahedy

Copy link
Copy Markdown
Member Author

@techmahedy I'll do it soon

take your time

Comment thread src/Phaseolies/Console/Commands/MakeSignalCommand.php Outdated
@techmahedy
techmahedy merged commit 99b7b06 into doppar:4.x Oct 15, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants