Sitemap

Implement soft-delete in Rails with Avo + Discard

6 min readMay 16, 2025

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 injects discard, 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’s default_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 to record 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!

--

--

Pentest Team @greenhats.com
Pentest Team @greenhats.com

Written by Pentest Team @greenhats.com

full time white hacking / pentesting company who always stays on bleeding edge - https://www.greenhats.com

No responses yet