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
deletedInside a surrounding transaction, they mean something narrower:
this database statement succeeded on this connectionThe 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 commitsThe 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 sentThe 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 publicationThe 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 publishThe 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 systemThis 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 visibleUse 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 allThe 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.”
Laravel 13.x, “Jobs & Database Transactions” , accessed 2026-07-25. ↩︎