The project is in a healthy, maintained state
Resolves foreign keys, enums, booleans and custom transformations from PaperTrail::Version into structured, UI-ready hashes.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

paper_trail-human

Gem Version CI

Transforms PaperTrail::Version records into structured, human-readable hashes ready for UI display — audit logs, timelines, activity feeds.

Resolves foreign keys to names, translates enums and constants, formats dates and numbers, and accepts custom transformations via lambda.

Table of Contents

  • 1. Introduction
    • 1a. Compatibility
    • 1b. Installation
    • 1c. Quick Start
  • 2. Configuration
    • 2a. Global Options
    • 2b. Per-Model Fields
    • 2c. Item Name
    • 2d. After Format Hook
    • 2e. Event Callbacks
  • 3. Resolvers
    • 3a. Relation
    • 3b. Enum
    • 3c. Boolean
    • 3d. Custom
    • 3e. Text
    • 3f. Date
    • 3g. Number
  • 4. Formatting
    • 4a. Single Version
    • 4b. Collection
    • 4c. Filtering Fields
    • 4d. Output Formats
  • 5. Timeline
  • 6. Extensibility
    • 6a. Custom Resolvers
    • 6b. Custom Formatters
  • 7. I18n
    • 7a. Field Names
    • 7b. Event Labels
  • 8. Architecture
  • 9. Requirements
  • 10. Contributing
  • 11. License

1. Introduction

1a. Compatibility

paper_trail-human ruby activerecord paper_trail
0.5.x >= 3.1 >= 6.1 >= 12.0
0.4.x >= 3.1 >= 6.1 >= 12.0
0.3.x >= 3.1 >= 6.1 >= 12.0
0.2.x >= 3.0 >= 6.1 >= 12.0
0.1.x >= 2.7 >= 5.2 >= 9.0

CI matrix (0.5.x):

Rails PaperTrail Ruby
6.1 ~> 12.0 3.1, 3.2, 3.3, 3.4
7.0 ~> 13.0 3.1, 3.2, 3.3, 3.4
7.1 ~> 14.0 3.1, 3.2, 3.3, 3.4
7.2 ~> 15.0 3.1, 3.2, 3.3, 3.4
8.0 ~> 15.0 3.2, 3.3, 3.4

1b. Installation

Add to your Gemfile:

gem "paper_trail-human"

Then run:

bundle install
rails generate paper_trail:human:install

The generator creates an initializer at config/initializers/paper_trail_human.rb.

Important: This gem reads from the object_changes column. If your versions table doesn't have it, add it:

rails generate paper_trail:install --with-changes
rails db:migrate

1c. Quick Start

# config/initializers/paper_trail_human.rb
PaperTrail::Human.configure do |config|
  config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
end

# Anywhere in your app
PaperTrail::Human.format(version)
# => {
#   user: "John",
#   event: "update",
#   model: "User",
#   item_id: 1,
#   created_at: 2026-05-29 12:00:00,
#   fields: [
#     { field: "Name", previous_value: "John", value: "John Smith" },
#     { field: "Company", previous_value: "Acme", value: "Globex" }
#   ]
# }

2. Configuration

2a. Global Options

PaperTrail::Human.configure do |config|
  # Resolve whodunnit IDs to names (default: nil, returns raw ID)
  config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }

  # Fields to exclude from output (default: %w[id created_at updated_at])
  config.ignored_fields = %w[id created_at updated_at]

  # Custom field name resolver (default: nil, uses I18n then humanize)
  config.field_name_resolver = ->(field, model) { ... }

  # Translate event names via I18n (default: false)
  config.translate_events = true

  # Post-processing hook (default: nil)
  config.after_format = ->(result, version) { result }
end

2b. Per-Model Fields

PaperTrail::Human.configure do |config|
  config.register "User" do |m|
    m.field :role, :enum, class_name: "UserRole", method: :label
    m.field :company_id, :relation, class_name: "Company", attribute: :name
    m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
    m.field :bio, :text, max_length: 100, diff: true
    m.field :due_date, :date, format: "%d/%m/%Y"
    m.field :salary, :number, format: :currency, unit: "R$"
    m.field :score, :custom, resolve: ->(v) { "#{v} points" }
  end
end

2c. Item Name

Adds a human-readable identifier for the record to the output:

config.register "User" do |m|
  m.item_name :name
  # or with a lambda:
  m.item_name ->(version) { "User ##{version.item_id}" }
end

PaperTrail::Human.format(version)[:item_name]
# => "João Silva"

The item_name key is only present when the record exists and the attribute is configured.

In batch mode (format_collection), item names are preloaded to prevent N+1 queries.

2d. After Format Hook

Post-process every formatted result:

config.after_format = ->(result, version) {
  result[:record_url] = "/#{result[:model].tableize}/#{result[:item_id]}"
  result
}

The lambda receives the formatted hash and the original PaperTrail::Version, and must return the hash.

2e. Event Callbacks

Register multiple callbacks that fire after formatting. Useful for side effects like logging, notifications, or external integrations:

config.on_format do |result, version|
  AuditLog.push(result) if result[:event] == "destroy"
end

config.on_format do |result, version|
  SlackNotifier.notify(result)
end
  • Multiple callbacks, executed in order of registration
  • Fault-tolerant: errors in one callback don't block others or the return value
  • Runs after the after_format hook

3. Resolvers

3a. Relation

Resolves a foreign key to an attribute of the associated model.

m.field :company_id, :relation, class_name: "Company", attribute: :name
Option Description Default
class_name: The associated model class required
attribute: Attribute to display :name

Arrays of IDs are also supported — each ID is resolved individually:

m.field :tag_ids, :relation, class_name: "Tag", attribute: :name
# [1, 3, 5] → ["Ruby", "Rails", "Docker"]

In batch mode (format_collection), relations are preloaded to prevent N+1 queries.

3b. Enum

Resolves enum values to human labels.

# With a class that responds to a method
m.field :role, :enum, class_name: "UserRole", method: :label

# With a static mapping
m.field :status, :enum, mapping: { "active" => "Active", "inactive" => "Inactive" }

# With Rails native enum
m.field :role, :enum, from_model: "User"
m.field :role, :enum, from_model: "User", labels: { admin: "Administrator" }
Option Description
class_name: + method: Calls ClassName.method(value)
mapping: Static hash lookup
from_model: Reads from Model.defined_enums
labels: Custom labels for from_model

3c. Boolean

Custom labels for boolean fields:

m.field :active, :boolean, true_label: "Active", false_label: "Inactive"

3d. Custom

Arbitrary transformation via lambda:

m.field :score, :custom, resolve: ->(value) { "#{value} points" }

3e. Text

Truncates long text fields with optional diff stats:

m.field :body, :text, max_length: 100, show_diff_stats: true
# => "Lorem ipsum dolor sit amet..." (250 chars)

Diff mode — shows line-level additions and deletions:

m.field :body, :text, diff: true
# => {
#   field: "Body",
#   previous_value: "Old text...",
#   value: "New text...",
#   additions: 5,
#   deletions: 2,
#   summary: "+5/-2 lines"
# }
Option Description Default
max_length: Maximum characters before truncation 80
show_diff_stats: Append total char count false
diff: Enable line-level diff stats false

3f. Date

Formats date/time values:

m.field :due_date, :date, format: "%d/%m/%Y"
# => "30/05/2026"
Option Description Default
format: strftime format string "%Y-%m-%d"

Accepts Date, Time, DateTime, and parseable strings.

3g. Number

Formats numeric values:

m.field :amount, :number, format: :currency, unit: "R$"
# => "R$ 1,500.99"

m.field :rate, :number, format: :percentage
# => "85.50%"
Option Description Default
format: :default, :currency, :percentage :default
unit: Currency symbol (for :currency) nil
precision: Decimal places 2
delimiter: Thousands separator ","
separator: Decimal separator "."

4. Formatting

4a. Single Version

PaperTrail::Human.format(version)

Returns a hash with keys: user, event, model, item_id, created_at, fields, and optionally item_name.

Event-specific behavior:

  • create: fields omit previous_value
  • update: fields include both previous_value and value
  • destroy: fields omit value

4b. Collection

PaperTrail::Human.format_collection(user.versions)

Same as format but for multiple versions. Relations and item names are batch-loaded to prevent N+1 queries.

When using as:, returns a single joined string (separated by blank lines for text/markdown, newlines for HTML).

4c. Filtering Fields

PaperTrail::Human.format(version, only: [:name, :email])
PaperTrail::Human.format(version, except: [:password_digest])

4d. Output Formats

By default, methods return hashes. Use as: for string output:

PaperTrail::Human.format(version, as: :text)
# => "Updated User#1 by John at 2026-05-30\n  • Name: Old → New"

PaperTrail::Human.format(version, as: :markdown)
# => Markdown with header and table

PaperTrail::Human.format(version, as: :html)
# => HTML div with table (XSS-safe, escapes entities)

Built-in formats: :text, :markdown, :html. Custom formats can be registered (see 6b. Custom Formatters).

Works with both format and format_collection.

5. Timeline

Group versions by time period:

PaperTrail::Human.timeline(user.versions, group_by: :day)
# => {
#   "2026-05-28" => [{ user: ..., fields: [...] }, ...],
#   "2026-05-30" => [{ user: ..., fields: [...] }]
# }
group_by Format Example
:day %Y-%m-%d "2026-05-30"
:week %G-W%V "2026-W22"
:month %Y-%m "2026-05"
:year %Y "2026"

Supports only: and except: filters.

6. Extensibility

6a. Custom Resolvers

Register your own resolver types:

class MoneyResolver
  include PaperTrail::Human::Ports::Resolver

  def initialize(currency: "USD", **)
    @currency = currency
  end

  def resolve(value)
    "#{@currency} #{format('%.2f', value.to_f)}"
  end
end

PaperTrail::Human.configure do |config|
  config.register_resolver :money, MoneyResolver

  config.register "Order" do |m|
    m.field :total, :money, currency: "R$"
  end
end

The resolver class must include PaperTrail::Human::Ports::Resolver and implement #resolve(value).

For resolvers that need both old and new values, implement #resolve_pair? returning true and #resolve_change(previous_value, new_value).

6b. Custom Formatters

Register your own output formats:

class JsonFormatter
  def call(result)
    result.to_json
  end
end

PaperTrail::Human.configure do |config|
  config.register_formatter :json, JsonFormatter
end

PaperTrail::Human.format(version, as: :json)

The formatter class must implement #call(result) and return a string.

7. I18n

7a. Field Names

Field names are resolved in this order:

  1. Custom field_name_resolver lambda (if configured)
  2. I18n.t("activerecord.attributes.model_name.field_name") (if I18n available)
  3. Automatic humanization (removes _id suffix, titleizes)

Example: company_id → looks up activerecord.attributes.user.company_id → falls back to "Company".

7b. Event Labels

Enable translated event labels:

config.translate_events = true

The gem includes locale files for en and pt-BR. Add your own:

# config/locales/paper_trail_human.en.yml
en:
  paper_trail_human:
    events:
      create: "Created"
      update: "Updated"
      destroy: "Destroyed"
# config/locales/paper_trail_human.pt-BR.yml
pt-BR:
  paper_trail_human:
    events:
      create: "Criação"
      update: "Atualização"
      destroy: "Exclusão"

8. Architecture

Hexagonal (Ports & Adapters):

┌─────────────────────────────────────────────┐
│                   Core                       │
│  ChangeExtractor · FieldFormatter            │
│  EventTranslator · AfterFormat               │
│  Presenter · BatchPresenter · Timeline       │
│  ItemNameLoader · RelationLoader             │
├─────────────────────────────────────────────┤
│                   Ports                       │
│  Resolver (interface)                        │
├─────────────────────────────────────────────┤
│                  Adapters                     │
│  Resolvers: Relation, Enum, Boolean,         │
│             Custom, Text, Date, Number       │
│  Formatters: Text, Markdown, Html            │
│  (+ user-registered resolvers/formatters)    │
└─────────────────────────────────────────────┘
  • Core — pure formatting logic, no external dependencies
  • PortsResolver interface that every adapter implements
  • Adapters — concrete implementations (lazy-loaded via autoload)

The gem has zero dependencies beyond activerecord and paper_trail. Adapters are loaded on demand. The Railtie is optional — it works in non-Rails apps (Sinatra, Hanami, etc).

9. Requirements

  • Ruby >= 3.1
  • Rails >= 6.1 (or standalone ActiveRecord)
  • PaperTrail >= 12.0

10. Contributing

See CONTRIBUTING.md for guidelines on setting up the development environment, running tests, and submitting pull requests.

11. License

MIT. See LICENSE.txt.