Implement soft-delete in Rails with Avo + Discard
A step-by-step guide (with copy-pastable code) to add a recycle-bin to any Rails app using Avo’s admin panel and the Discard gem.
Why soft-delete?
- Safety first — deleting the wrong record in production is costly; with soft-delete you can undo mistakes.
- Auditability — a “deleted” record is still queryable for investigations, analytics, refunds, GDPR export, etc.
- User experience — customer-facing features (for example, “reactivate account”) are trivial once the data still exists.
- Ops peace-of-mind — bulk deletes now become two steps: discard (immediately invisible) and later hard-delete by cron/job.
- Admin productivity — surfacing discarded records in the back-office lets support teams restore data without touching the console.
Soft-delete is therefore a must for most serious web apps. Below is a complete recipe to add it in minutes with a nice interface. Of course, this is only a proof of concept. However, you can easily extend its functionality. For example, you could use the audited gem to display the user who deleted the record.
Prerequisites
1 — Avo
Avo is a Rails admin-panel / CMS that turns plain models into polished CRUD UIs in minutes. It is highly customizable, integrates with Pundit/CanCanCan, works with Hotwire, and ships a resource DSL that we’ll leverage for our recycle-bin. Installing this gem is beyond the scope of this blog post. Please refer to the documentation.
2 — Discard
Discard adds minimal, default-scoped soft-delete to ActiveRecord. It uses a single discarded_at
column plus helper scopes (kept
, discarded
) and callbacks.
gem "discard", "~> 1.4"
bundle install
1. Make a model discardable
We’ll use Account as the example, but the pattern works for every model.
# app/models/account.rb
class Account < ApplicationRecord
include Discard::Model
# Hide discarded records from normal queries
default_scope -> { kept }
# Custom business rule: never allow admins to be discarded
def discardable?
return false if discarded? || has_role?(:admin)
true
end
# ...
end
What happens?
include Discard::Model
injectsdiscard
,undiscard
,discarded?
, scopes, and callbacks.default_scope -> { kept }
silently excludes discarded rows from all default ActiveRecord and Avo queries.discardable?
is our custom guard-rail, used later by the discard action to prevent deleting protected records. This is optional and uses the rolify gem.
Add the column
bin/rails g migration add_discarded_at_to_accounts discarded_at:datetime:index
bin/rails db:migrate
2. Avo recycle-bin — the universal DiscardedRecord resource
Because discarded rows can live in multiple tables, we’ll build a virtual resource backed by an array, not an ActiveRecord model.
bin/rails generate avo:resource DiscardedRecord --array
Replace the generated file with:
# app/avo/resources/discarded_record.rb
class Avo::Resources::DiscardedRecord < Avo::Resources::ArrayResource
self.description = "Shows all soft-deleted items across the application"
self.title = :id
self.index_controls = -> {
action Avo::Actions::Undiscard,
style: :primary,
color: :primary,
icon: "heroicons/solid/arrow-uturn-left"
}
# Row-level controls
self.row_controls = -> do
show_button
action Avo::Actions::Undiscard,
title: "Undiscard",
icon: "heroicons/solid/arrow-uturn-left",
style: :icon
end
# ----------------------------------
# DATA SOURCE
# ----------------------------------
def records
Rails.application.eager_load! # ensure all models are loaded
discarded_records = []
ActiveRecord::Base.descendants.each do |model|
next if model.abstract_class?
next unless model.included_modules.include?(Discard::Model)
model.unscoped do # ignore each model's default_scope
model.discarded.find_each.with_index do |record, id|
discarded_records << {
id: id,
model_type: model.name,
record: record,
discarded_at: record.discarded_at
}
end
end
end
# newest first
discarded_records.sort_by { |r| r[:discarded_at] }.reverse
end
# ----------------------------------
# UI FIELDS
# ----------------------------------
def fields
field :preview, as: :preview
field :model_type, as: :text
field :record, as: :record_link
field :object, as: :text, hide_on: :index, show_on: :preview do
"<pre style='white-space:pre-wrap'>" \
"#{CGI.escapeHTML(JSON.pretty_generate(JSON.parse(record.record.to_json)))}" \
"</pre>".html_safe
end
field :discarded_at, as: :text
end
# ----------------------------------
# ACTIONS
# ----------------------------------
def actions
action Avo::Actions::Undiscard, icon: "heroicons/solid/arrow-uturn-left"
end
end
Highlights
- Dynamic model list — the resource loops over every loaded model that
include Discard::Model
. unscoped { … discarded }
– bypasses each model’sdefault_scope
to fetch all discarded rows.- JSON preview— Pretty render the full record to json and display them in the preview
3. Pundit policy
Keep it simple for now — only admins (rolify) can see the recycle-bin:
# app/policies/avo/discarded_record_policy.rb
class Avo::DiscardedRecordPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve; end # empty because no needed for array resources
end
def index?
user.has_any_role? :admin
end
def show?
index?
end
def preview?
index?
end
def act_on?
user.has_any_role? :admin
end
end
4. Actions — soft-delete & undo
4.1 Discard action (mass-delete on any resource)
# app/avo/actions/discard.rb
class Avo::Actions::Discard < Avo::BaseAction
self.name = 'Soft Delete'
self.message = 'This will mark the selected records as deleted. The records will not be visible in the application anymore but can be restored for 30 days. After 30 days, the account and all associated data will be automatically deleted.'
self.visible = -> do
view.index? || view.show?
end
self.authorize = -> do
current_user.has_any_role? :admin
end
def handle(query:, fields:, current_user:, resource:, **args)
succeed_count = 0
error_count = 0
query.each do |record|
if record.respond_to?(:discardable?)
if record.discardable?
record.discard
succeed_count += 1
else
error_count += 1
end
elsif record.undiscarded?
record.discard
succeed_count += 1
else
error_count += 1
end
end
error "Could not discard #{error_count} #{'record'.pluralize(error_count)}. Please check the logs for more information." if error_count.positive?
succeed "Successfully discarded #{succeed_count} #{'record'.pluralize(succeed_count)}." if succeed_count.positive?
end
end
4.2 Undiscard action (restore from recycle-bin)
# app/avo/actions/undiscard.rb
class Avo::Actions::Undiscard < Avo::BaseAction
self.name = 'Restore'
self.message = 'Are you sure you want to restore the selected records?'
self.visible = -> do
view.index? || view.show?
end
self.authorize = -> do
current_user.has_any_role? :admin
end
def handle(query:, fields:, current_user:, resource:, **args)
succeed_count = 0
error_count = 0
query.map(&:record).each do |record|
if record.discarded?
record.undiscard
succeed_count += 1
else
error_count += 1
end
end
error "Could not undiscard #{error_count} #{'record'.pluralize(error_count)}. Please check the logs for more information." if error_count.positive?
succeed "Successfully undiscarded #{succeed_count} #{'record'.pluralize(succeed_count)}." if succeed_count.positive?
end
end
Note: we map
query
torecord
because the recycle-bin rows wrap the real object in the:record
key.
5. Menu entry
# config/initializers/avo.rb
# ...
resource :discarded_records, visible: -> { current_user.has_any_role? :admin }, label: 'Recycle bin'
# ...
Now any admin can browse /avo/discarded_records, check discarded items, preview the JSON payload, and restore them with one click.
6. The account resource
Use row controls and the actions to add the discard button directly to the account resource.
# app/avo/resources/account.rb
class Avo::Resources::Account < Avo::BaseResource
# ...
self.row_controls = -> do
show_button
edit_button
delete_button
action Avo::Actions::Discard, title: 'Discard', icon: 'heroicons/outline/user-minus', style: :icon if current_user.has_any_role?(:company_admin, :account_editor, :admin) && record.undiscarded?
end
def actions
action Avo::Actions::Discard
end
# ...
Now any admin can browse /avo/discarded_records, check discarded items, preview the object as JSON , and restore them with one click.
7. House-keeping — permanent deletion
Soft-delete is half the story; remember to really delete after the legal/business grace period:
# lib/tasks/hard_delete.rake
namespace :maintenance do
desc "Permanently remove records discarded more than 30 days ago"
task hard_delete: :environment do
days = (ENV["DAYS"] || 30).to_i
cutoff = days.days.ago
ActiveRecord::Base.descendants.each do |model|
next unless model.included_modules.include?(Discard::Model)
model.discarded.where(model.arel_table[:discarded_at].lt(cutoff)).find_each(&:destroy)
end
end
end
Run from cron or Sidekiq-Cron:
bin/rails maintenance:hard_delete # default 30 days
bin/rails maintenance:hard_delete DAYS=7 # override
Conclusion
With a few lines of code you now have:
- Per-resource Soft Delete baked directly in the Avo interface.
- A Recycle bin that aggregates discarded rows from all models.
- One-click restore
- Automatic data retention by combining Discard with a nightly task.
Happy shipping — and enjoy the peace-of-mind that comes with reversible deletes!