We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
35. Schedule Delete
Register to get the Starter Kit, every episode, and weekly deep dives.
Permanently delete database records after a delay — with the option to undo
Deleting a record immediately is straightforward, but it leaves no room for recovery. A common approach is to mark a record as deleted first, then remove it from the database after a short delay. This gives users a window to undo the action before it becomes permanent.
This screencast walks through building that pattern in Phoenix LiveView using Ecto, Oban, and Phoenix PubSub — from the database migration to the real-time table update when the job finally runs.
What this covers
- Adding a
deleted_attimestamp column to soft-delete records instead of removing them immediately - Scheduling a hard delete with Oban, with a configurable delay before permanent removal
- Using Phoenix PubSub to broadcast deletions and update the LiveView table in real time — without a page refresh
- Applying conditional CSS classes in the table component to visually mark deleted rows, using Tailwind's parent-targeting syntax
- Implementing an undo function that clears
deleted_at, which the Oban job checks before proceeding with deletion - Extending a shared table component with a
row_deletedattribute without breaking other usages of the same component
A taste of what you'll build
Scheduling the hard delete through Oban after soft-deleting a record:
def soft_delete_brewery(%Brewery{} = brewery) do
changeset = Ecto.Changeset.change(brewery, %{deleted_at: DateTime.utc_now(:second)})
with {:ok, brewery} <- Repo.update(changeset),
{:ok, _job} <- schedule_hard_delete(brewery) do
{:ok, brewery}
end
end
defp schedule_hard_delete(brewery) do
%{id: brewery.id}
|> HardDeleteBreweryWorker.new(schedule_in: 60)
|> Oban.insert()
end
The Oban worker that runs the permanent delete — and handles the case where the record was already restored:
def perform(%Oban.Job{args: %{"id" => id}}) do
case Breweries.hard_delete_brewery(id) do
{:ok, _brewery} -> :ok
{:error, :not_found} -> :ok
end
end
The not_found case is intentional. If a user undoes the deletion before the job runs, deleted_at is cleared and the hard delete query finds nothing — the job completes cleanly either way.