660 words, 4 min read

Laravel's events and listeners are a fantastic way to decouple your application. You dispatch an event, register one or more listeners, and each listener reacts independently. It's a simple pattern that keeps your code clean.

Phoenix doesn't provide an equivalent abstraction out of the box, but OTP and Phoenix PubSub make it straightforward to build something similar while remaining idiomatic.

Publishing domain events

Rather than having models emit events automatically, publish events from your contexts after a successful database operation.

def update_user(user, attrs) do
old = user
with {:ok, user} <- User.changeset(user, attrs) |> Repo.update() do
Events.publish(%Events.UserUpdated{
old: old,
new: user,
actor_id: attrs[:actor_id]
})
{:ok, user}
end
end

Notice that the event describes a business action (UserUpdated) rather than a database operation.

Creating an event bus

A small wrapper around Phoenix PubSub provides a central place for publishing events.

defmodule MyApp.Events do
@topic "events"
def publish(event) do
Phoenix.PubSub.broadcast(
MyApp.PubSub,
@topic,
{:event, event}
)
end
end

This gives you an API similar to Laravel's event(...) helper.

Listening for events

Listeners are simply GenServers that subscribe to your event topic.

defmodule MyApp.Audit.Listener do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{})
end
def init(state) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "events")
{:ok, state}
end
def handle_info({:event, event}, state) do
handle_event(event)
{:noreply, state}
end
defp handle_event(%Events.UserUpdated{} = event) do
Audit.log(event)
end
defp handle_event(_), do: :ok
end

Adding additional listeners for emails, metrics, search indexing or webhooks is simply a matter of starting another GenServer.

Is this suitable for audit logging?

Not quite.

Phoenix PubSub is excellent for notifying other parts of your application, but it does not guarantee that a listener has successfully processed an event.

Imagine the following sequence:

Repo.update(...)
Publish event
Application crashes

The database change has been committed, but the audit record may never be written.

For an audit trail, that's usually unacceptable.

Use Ecto.Multi for audit logging

A more robust approach is to store the audit record in the same transaction as the database change.

Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.insert(:audit_log, fn %{user: user} ->
AuditLog.changeset(%AuditLog{}, %{
entity: "user",
entity_id: user.id,
action: "updated"
})
end)
|> Repo.transaction()

Now either both records are committed, or neither is.

Capturing the changes

You don't need to manually compare two structs. Ecto already tracks modified fields through the changeset.

changeset.changes

For more detailed audit logs, you can combine the original struct with the updated one to produce a before/after representation.

For example:

{
"name": {
"old": "John",
"new": "Johnny"
},
"email": {
"old": "john@example.com",
"new": "johnny@example.com"
}
}

This makes it easy to display exactly what changed in an audit history.

Putting it together

A pattern that has worked well for larger Phoenix applications is to separate business events from auditing:

  • Publish domain events with Phoenix PubSub for emails, search indexing, webhooks and other asynchronous work.
  • Persist audit logs inside the same database transaction using Ecto.Multi.
  • Offload expensive work to Oban jobs instead of executing it directly from listeners.

This gives you the same loose coupling as Laravel's event system while embracing Elixir's strengths: explicit processes, OTP supervision and reliable transactional guarantees.