Index

Design Before the Pattern Name

Framework Convenience Has a Boundary

A model was saved inside a database transaction. Its observer dispatched a queue job. The worker started quickly, looked for the record which described its work, and found nothing.

The record existed in the transaction which dispatched the job. It was not yet visible to the worker’s database connection.

Retained history connects that race to successful wallet payments without a corresponding wallet transaction. The job had been published. The state it needed had not.

The framework did exactly what the code asked. The shortcut failed because its lifecycle was smaller than the business operation.

A saved event does not mean committed

Model lifecycle names sound conclusive:

created
updated
saved
deleted

Inside a surrounding transaction, they mean something narrower:

this database statement succeeded on this connection

The transaction may still roll back. Other connections may still be unable to see the row. Another statement may still fail.

That difference is harmless when an observer updates local in-memory state or maintains data within the same transaction. It becomes a correctness boundary when the observer:

  • sends HTTP;
  • sends mail or a notification;
  • publishes an event to another process;
  • dispatches a queue job which can start immediately; or
  • writes to a second system with an independent commit.

Those effects can outlive a rollback or arrive before the data they describe.

The race has two bad outcomes

Before-commit publication creates two distinct failures.

The first is early consumption:

transaction inserts export record
observer dispatches job
worker starts
worker cannot see export record
transaction commits

The worker may return, fail, or retry depending on its missing-record policy. None of those choices makes the original timing correct.

The second is false publication:

transaction changes model
observer sends notification
later statement fails
transaction rolls back
notification remains sent

The recipient has learned a fact the database rejected.

These failures need different tests. A test which only asserts “the job was queued” proves neither record visibility nor rollback suppression.

After-commit dispatch closes the first window

Laravel jobs can request after-commit dispatch:

final class ProcessExport implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly string $exportId,
    ) {
        $this->afterCommit();
    }
}

Laravel documents that jobs using this behavior are sent only after open database transactions commit and are discarded when the transaction rolls back.1

commit
    -> publish job
    -> worker can load committed record

rollback
    -> discard deferred publication

The retained correction marked every profile-export job this way. A separate retained fix marked a queued support notification after commit because its worker could otherwise try to restore a task which was not visible yet.

Both cases share one rule: when queued work needs a record created by the current transaction, the handoff belongs after that transaction commits.

After commit is not an outbox

Deferral changes the failure window. It does not remove it.

Consider:

database commits
process crashes before queue publish

The state is durable, but the reaction may never be dispatched. An after-commit callback has no durable pending-publication record of its own.

That can be acceptable when:

  • the effect is non-critical;
  • a periodic repair can find missing work;
  • the operation is safe to request again; or
  • the business can tolerate manual recovery.

Use an outbox when the handoff itself must survive process failure. Write the business state and an outbox row in the same transaction, then let a separate publisher deliver and mark that row.

The framework shortcut is not wrong. It solves commit ordering, not durable publication.

Keep observers inside a narrow capability budget

Observers are attractive because they cover every save path without changing callers. That same reach makes surprises expensive.

A useful observer budget is:

allowed
    maintain local derived state in the same transaction
    record local audit data with an explicit failure policy
    dispatch explicitly after commit

not allowed
    direct HTTP
    direct mail or notification delivery
    immediate queue publication which consumes current transaction state
    hidden calls to another durable system

This is not a universal ban on observers. A local slug, revision row, or denormalized value may fit the model lifecycle exactly. The observer should still be small enough that a caller can understand what save() entails.

If the effect needs authorization, retry policy, compensation, or product intent from the use case, an explicit application service is usually the better owner.

Static analysis can guard the obvious exits

The retained application added a configured analyzer rule for observer classes. It reports direct:

  • framework HTTP calls;
  • typed framework HTTP clients;
  • connector sends;
  • framework mail sends; and
  • notification delivery.

It permits local persistence, unrelated local methods also named send, non-observer classes, and explicitly after-commit queue dispatch.

At introduction, the configured source tree had no violations. That is a rollout fact, not proof that observers perform no external I/O.

The rule deliberately uses the application’s observer naming convention. It does not inspect arbitrary classes or follow calls hidden behind application services. This passes undetected:

final class AccountObserver
{
    public function saved(Account $account): void
    {
        $this->synchronizer->execute($account);
    }
}

If execute() performs HTTP, a syntax-level observer rule may not know. Static analysis protects the direct boundary it can describe. Code review and capability-level contracts still own indirection.

Test timing, not only configuration

The retained regression tests assert that the jobs and notifications are configured for after-commit behavior. That protects the marker. It does not run a worker on another database connection during an open transaction.

A stronger behavior suite has four cases:

before-commit control
    worker cannot see uncommitted record

commit
    deferred job becomes visible and loads record

rollback
    deferred job is not published

post-commit publish failure
    committed record remains and recovery requirement is visible

Use the real database and queue driver where practical. A fake queue can inspect configuration but may not reproduce commit callbacks or worker visibility.

The public reproduction executes those state transitions with a synthetic transaction and queue. It proves the timing model, not Laravel, the private database, or a concurrent production worker.

Move the boundary when the lifecycle no longer fits

Framework convenience is valuable while its lifecycle matches the operation. The moment a hook needs committed visibility, reliable publication, or an explicit product decision, keep the convenience at the edge and move the effect behind a boundary with the right contract.

For a model observer, that may mean:

observer
    -> dispatch after commit

application service
    -> write state and outbox together

explicit use case
    -> choose whether the effect should happen at all

The correct choice depends on the failure the business can tolerate. What is not safe is to equate “the framework called saved” with “the rest of the system may now believe this fact.”