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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.