layered-resource-rails
An open source, Rails 8+ engine that provides convention-over-configuration CRUD scaffolding. Define a resource class and a single route, and you get index, show, new/create, edit/update, and destroy actions with search and pagination. Built on top of layered-ui-rails, Ransack, and Pagy.
Why use it
Most Rails apps need an admin area, an internal dashboard, or a "list and edit some records" screen long before they need anything bespoke. layered-resource-rails gets you there in a few lines, then stays out of your way as your needs grow:
- Skip the boilerplate. Declare your columns, fields, and search - get index, show, forms, search, sort, and pagination for free. No scaffold to maintain, no half-finished admin gem to fight.
- Looks right out of the box. Tables, forms, and pagination come pre-styled via layered-ui-rails with WCAG 2.2 AA compliance and dark mode included.
- Override only what you need. Swap a single view partial, subclass the controller for a custom scope or redirect, or generate plain ERB to take full control - without rewriting the rest.
- Eject cleanly if you outgrow it. Generate a standard Rails controller and views, drop the gem, and you're left with idiomatic Rails. No lock-in, no hidden coupling.
Agent skill
An agent skill is included so AI coding agents can work with layered-resource-rails in your project. Once installed, the agent can handle the full setup - just ask it to add layered-resource-rails to your app and it will install the gem, scaffold resources, and wire up routes.
Project install - scoped to a single repo, available to all contributors:
bin/rails generate layered:resource:install_agent_skill
Global install - available across all your projects:
./install-skill.sh
Or install remotely without cloning the repo:
curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-resource-rails/main/install-skill.sh | sh
Requirements
- Ruby on Rails >= 8.0
- layered-ui-rails ~> 0.25 (>= 0.25.1)
- Ransack ~> 4.0
- Pagy ~> 43.2
Getting started
Add to your Gemfile and install:
bundle add layered-resource-rails
layered-resource-rails depends on layered-ui-rails for its UI. If you haven't already set it up, run its install generator:
bin/rails generate layered:ui:install
Quick start
The fastest path: scaffold the model, migration, resource, and route in one shot.
rails g layered:resource:scaffold post title:string body:text
This invokes Rails' built-in model generator (so you get the migration and model), writes app/layered_resources/post_resource.rb with columns and fields derived from the attributes, and appends layered_resources :posts to config/routes.rb. Views are intentionally not generated - the gem's defaults render until you eject them with rails g layered:resource:views posts.
Pass --skip-model if the model already exists. Restrict which CRUD actions get routed with --actions index show (emits only:) or --except destroy (emits except:). Pass --controller to also eject a controller and wire it into the route, or --views to eject the templates upfront.
If the model already exists and you just want the resource class plus its route, use rails g layered:resource post title:string body:text instead. It writes app/layered_resources/post_resource.rb and appends layered_resources :posts - pass --skip-route to skip the route line.
The rest of this section walks through what the generator produces.
1. Define a resource
Create a file at app/layered_resources/post_resource.rb:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :status },
{ attribute: :created_at, label: "Published" }
]
search_fields [:title]
default_sort attribute: :created_at, direction: :desc
fields [
{ attribute: :title },
{ attribute: :body, as: :text },
{ attribute: :status }
]
end
2. Add the route
In config/routes.rb:
Rails.application.routes.draw do
layered_resources :posts
end
That's it. You now have a full CRUD interface with search and pagination for Post.
What you get
| Route | Action | Description |
|---|---|---|
GET /posts |
index | Paginated table |
GET /posts/:id |
show | Post detail page |
GET /posts/new |
new | New post form |
POST /posts |
create | Create post |
GET /posts/:id/edit |
edit | Edit post form |
PATCH /posts/:id |
update | Update post |
DELETE /posts/:id |
destroy | Delete post |
The index table's primary column (primary: true, or the first column) links to each record's edit page - or, for read-only resources without an edit action, to its show page.
Row actions (Edit/Delete) live in a popover menu in the table's last column. That column is pinned to the right-hand edge of the table's scroll container, so the actions menu stays reachable when a wide table scrolls horizontally.
Each action also sets @page_title for use by the layout's <title> tag - "Posts" on index, "New Post" on new, the record's primary column value on show, and "Edit <record label>" on edit. Override after super in a custom action if you need something different.
Options
Read-only (no forms): omit fields and restrict routes:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :created_at, label: "Published" }
]
end
layered_resources :posts, only: [:index]
Restrict actions with only: or except::
layered_resources :posts, only: [:index, :show, :edit, :update]
layered_resources :posts, except: [:destroy]
The default show view is intentionally a blank canvas - the gem doesn't auto-render an attribute list, since generated detail pages tend to be low-value and always need customizing. The index links each record's title to its edit page, not its show page, so show is only worth keeping if you're going to build a real detail view (eject it with rails g layered:resource:views and fill in the template). If you're not, pass except: [:show] to drop the route.
Root breadcrumb: top-level resources render no breadcrumb trail by default. Declare a static first crumb — typically a link back to the host app's dashboard:
class PostResource < Layered::Resource::Base
model Post
"Home", "/"
end
Nested routes prepend it to the derived parent trail (e.g. Home / Users / Alice). Pass nil as the path to render unlinked text.
Record label: a record is labelled by its primary column (the one marked primary: true, else the first) wherever the gem has to name it: the show and edit page titles, a row's actions menu, and its options in another resource's picker. Declare label_attribute when that column isn't the record's name — a primary: column rendered by a render: proc, say:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :headline, primary: true, render: ->(record, view) { view.tag.strong(record.headline) } },
{ attribute: :created_at }
]
label_attribute :title
end
When the attribute has no value, the label falls back to the first of name/title/label/email that does, then to the model's own to_s if it defines one, and finally to "Post #12" — never a bare #<Post:0x...>.
Custom scope (e.g. tenant isolation):
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
controller.current_team.posts
end
end
Ownership
For the common case where every record belongs to the signed-in user (or
tenant), use the owned_by shorthand:
class QuoteResource < Layered::Resource::Base
model Quote
owned_by :user # via :current_user (default)
# owned_by :account, via: :current_account
end
owned_by is purely behavioural - it expands to:
scope(controller)returnsQuote.where(user: controller.current_user), so the index, show, edit, etc. only see records the user owns.build_record(controller)assigns the owner on new records, socreatestampsuser_idautomatically.- When
controller.current_userisnil,owned_byraisesLayered::Resource::MissingOwnerErrorso a missingauthenticate_user!surfaces immediately instead of every page silently 404ing. Passallow_nil: truefor genuinely public-with-scope behaviour (returnsQuote.noneand assigns nil on create). Withuse_pundit, the policy gate (policy.create?) decides —owned_bylets nil through.
owned_by is a fact about the data, not a gate on what users can do. For
per-action authorisation ("can this user edit this record?") use
use_pundit or override scope yourself.
Authorisation
scope(controller) is the universal seam - any read filter (Pundit,
CanCan, a plain PORO) goes there. The gem also ships first-class Pundit
support via opt-in:
class PostResource < Layered::Resource::Base
model Post
use_pundit
end
When enabled:
scope(controller)defaults to the controller'spolicy_scope(model)helper - the index reads throughPolicy::Scope#resolve. This routes through Pundit'spundit_user, so apps that authorize ascurrent_account(or any other identity) get the same context here as inauthorizecalls.- Every action that loads or builds a record (
new,create,show,edit,update,destroy, plus any custom member action declared in alayered_resourcesblock) callsauthorize(@record)automatically. Pundit raisesPundit::NotAuthorizedErroron denial; handle it in yourApplicationControlleras you would for any Pundit-backed app. - Action buttons hide automatically: the
Newlink on the index, theEdit/Deleteitems in the index row's actions popover, and theEdit/Deletebuttons on the show page checkpolicy(record).new?/update?/destroy?so users only see actions they can perform.
verify_authorized/verify_policy_scoped: if yourApplicationControllerruns Pundit'safter_action :verify_authorized(or:verify_policy_scoped) globally, layered resources that don't opt intouse_punditwill raiseAuthorizationNotPerformedbecause the controller never callsauthorizefor them. Either skip those checks for the layered controller (skip_after_action :verify_authorized, if: -> { is_a?(Layered::Resource::ResourcesController) }) or scope the hardening to the controllers you actually want it on.
owned_by composes with use_pundit: Pundit owns the read filter
(Policy::Scope wins over the owner-where), and owned_by still drives
owner assignment on create. Stack them when the policy needs to express
more than ownership:
class PostResource < Layered::Resource::Base
model Post
use_pundit
owned_by :user
end
CanCan, plain POROs, and bespoke policies are still supported - just
override scope (and add per-action before_action :authorize_* callbacks
in an ejected controller). Use use_pundit when Pundit is the right fit
and you want the integration without writing it.
Writing policies
Two conventions worth knowing:
- Compare ids, not associations. Inside policy methods write
record.user_id == user.idrather thanrecord.user == user. The id comparison avoids loading the association and works on unsaved records. - The policy queried matches the route, not the model being mutated.
For a custom member action declared on
layered_resources :questions(e.g.POST /questions/:id/submit_answer),@recordis aQuestionand the gem callsQuestionPolicy#submit_answer?— even if the action ultimately creates anAnswer. Put the predicate on the policy for the resource the route lives on.
Per-record gating in custom views
Inside ejected views, the resource_can?(action, record = nil) helper
returns true only when both the route exposes the action and (when
Pundit is enabled) the policy permits it for the given record. Use it
wherever you'd previously read @resource_can_*:
<% if resource_can?(:update, @record) %>
<%= link_to "Edit", edit_post_path(@record) %>
<% end %>
The raw @resource_can_* ivars remain available for route-only checks
(no per-record policy lookup).
Associations
Resources are independent - each model gets its own resource class. To surface association data on an index, add a virtual column whose attribute: is a method on the model. For Post belongs_to :user, expose user.name by delegating on the model:
class Post < ApplicationRecord
belongs_to :user
delegate :name, to: :user, prefix: true, allow_nil: true # post.user_name
end
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :user_name, label: "Author" },
{ attribute: :created_at, label: "Published" }
]
end
Searching across associations
search_fields accepts association-walking entries in Ransack's <association>_<attribute> form. An entry that isn't a column on the resource's own model but matches an association plus a column on the associated model (e.g. :user_name for belongs_to :user and users.name) makes the index search box join into the association:
class PostResource < Layered::Resource::Base
model Post
search_fields [:title, :body, :user_name] # user_name searches users.name
end
The Ransack allowlists are scoped to the resource: the association and the associated model's attribute are only ransackable when this resource is the one searching, so nothing else gains access to the associated model and any host-app Ransack config is preserved.
A walked search field is also sortable — q[s]=user_name asc orders the index by users.name — because Ransack derives its sort allowlist from the search allowlist. Associations not declared in search_fields stay unsearchable and unsortable.
Search placeholder
The index search box's placeholder is derived from search_fields via human_attribute_name, so attribute renames declared in the standard Rails i18n location flow through automatically. Given search_fields [:title, :user_sid]:
# config/locales/en.yml
en:
activerecord:
attributes:
user:
sid: Identifier
produces "Search by title, user identifier". Association walks are labelled <association> <attribute>, each half resolved against its own model's human names (so translating post.user changes the "user" half too).
To replace the derived text wholesale, declare search_placeholder:
class PostResource < Layered::Resource::Base
model Post
search_fields [:title, :body, :user_sid]
search_placeholder "Search by title, body or author identifier"
end
Nested routes
To scope posts to a user (/users/:user_id/posts), nest the route inside a Rails resources :users do block or an explicit scope "users/:user_id". Either form produces the standard Rails nested-resources helper names (user_posts_path, user_post_path, new_user_post_path, …) — polymorphic_path([@user, :posts]) and link_to "Edit", [@user, @post] resolve to them with no extra wiring:
# config/routes.rb
layered_resources :users
resources :users, only: [] do
layered_resources :posts
end
# …or, equivalently…
scope "users/:user_id" do
layered_resources :posts
end
Don't add
as:to a surroundingscope.layered_resourcesderives its own helper names from the path segments (scope path: "manage"→manage_posts_path,new_manage_post_path, …), so anas:is unnecessary — and a value that disagrees with the path is ignored with a warning.
Resolve the parent in the resource's scope:
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
if controller.params[:user_id].present?
User.find(controller.params[:user_id]).posts
else
Post.all
end
end
end
Linking columns to a nested index
A column on the parent can link to its children's index using link: with the nested route's key:
class UserResource < Layered::Resource::Base
model User
columns [
{ attribute: :name, primary: true },
{ attribute: :email },
{ attribute: :posts_count, label: "Posts", as: :badge, rounded: true, link: :user_posts }
]
end
The posts_count cell on each user row links to /users/:id/posts. link: wraps the column's normal rendering in a link, so it composes with as: — pair it with as: :badge if you want a badge link.
Use a counter cache for child counts, not a virtual child.size column. Render the count as a rounded badge (as: :badge, rounded: true) so it reads as a count, not a value. Point the column at a real counter-cache attribute and add counter_cache: true to the child's belongs_to:
class Post < ApplicationRecord
belongs_to :user, counter_cache: true # maintains users.posts_count
end
# add_column :users, :posts_count, :integer, default: 0, null: false
A counter-cache column reads straight off the parent row, so the index renders in one query. A virtual column backed by user.posts.size issues a COUNT per row (an N+1) and can't be sorted or searched. Once posts_count is a real column it sorts via q[s]=posts_count asc like any other.
Filters
Where search_fields gives a single free-text box, filters adds structured controls for narrowing the index by specific attributes. The UI follows the "add filter" pattern: an Add filter button opens a popover listing the declared filters; picking one adds it as a tag with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Booleans and short single-choice selects apply instantly on click; multi-selects, comboboxes, ranges, and text filters have an Apply button.
Under the hood every filter is a Ransack predicate in the query string, so filters compose with search, sort, and pagination — all coexist in the URL and survive each other's submits (the search form and each filter form round-trip the other params as hidden fields; no JavaScript involved). The lightweight f[] param records which tags were added and in what order — new tags join the end of the row (after any pinned ones) and stay put when set; a tag's ✕ removes its entry.
Declare filters with a list of attributes. The control and predicate are inferred from each column:
class PostResource < Layered::Resource::Base
model Post
filters :status, # enum column -> multi-select of its values (status_in)
:featured, # boolean column -> Yes / No (featured_eq)
:created_at, # date/datetime -> from / to date range (created_at_gteq / _lteq)
:comments_count, # integer/decimal -> from / to number range (comments_count_gteq / _lteq)
:user # belongs_to -> multi-select of associated records (user_id_in)
end
| Column type | Control | Ransack predicate |
|---|---|---|
enum |
multi-select of the enum's values | _in (or _eq with multiple: false) |
boolean |
Yes / No | _eq |
date / datetime |
from–to date range | _gteq + _lteq |
integer / decimal / float |
from–to number range | _gteq + _lteq |
belongs_to |
multi-select of associated records | <foreign_key>_in (or _eq) |
string / text |
text "contains" (multi-select if a collection: is given) |
_cont (or _in / _eq) |
Select-type filters default to multi-select: filtering via the _in predicate. Pass multiple: false for a single-choice select via _eq.
A belongs_to filter keys on the foreign-key column (user_id), so it never joins — no association-walk setup is needed, unlike search_fields. By default its options are klass.all, labelled by the first present of name/title/label/email; pass a collection: to scope, order, or label differently. As with search, every filtered attribute is added to the resource's Ransack allowlist; attributes that are neither shown, searched, nor filtered stay un-queryable and any q[...] referencing them is silently ignored rather than raising.
Long option lists
How a select-type filter renders depends on how many options it turns out to have — a checkbox list of every user is no way to pick one:
| Options | Multi-select | Single-choice |
|---|---|---|
| up to 10 | checkbox list, Apply button | list of links, applying instantly on click |
| more than 10 | combobox — type-ahead, selections become removable tokens | single-select combobox, Apply button |
The count is taken per request (a collection: callable resolves first), so a filter follows its data rather than a guess made when the resource was written. The threshold is global:
# config/initializers/layered_resource.rb
Layered::Resource.filter_combobox_threshold = 25
Declaring as: pins the control and opts out of the switch entirely — as: :select keeps the plain list however long it gets, as: :combobox uses the combobox however short:
filters status: { as: :combobox }, # always the type-ahead
user: { as: :select } # always the checkbox list
Remote filter options
Past a few thousand records, rendering the options at all is the problem. Point a filter at an endpoint with url: and its options are fetched as the user types instead — the filter is then always a combobox, since there is no collection to render or count:
class PostResource < Layered::Resource::Base
model Post
filters user: { url: -> { }, min_chars: 2 }
end
Give url: as a callable so it resolves per request in the view, where route helpers are available. The endpoint is an ordinary action in your app — the gem never routes it — so it's authorised however any index is:
class UserOptionsController < ApplicationController
include Layered::Ui::ComboboxOptions
def index
render json: (policy_scope(User), label: :name, search: [:name, :email])
end
end
A remote combobox has no collection in the browser to look a label up in, so an active filter's current values are labelled server-side from the records themselves (the same name/title/label/email fallbacks) — the tag reads "User: Alice", not "User: 12". A url: on a plain column has no records to read, so its values label themselves.
Label lookups are not scoped. Whatever scoping the
url:endpoint applies covers the options it serves; it does not cover the labels. Current values arrive in the query string, and the gem labels them with an unscopedklass.where(id: ...)— so hand-editing?q[user_id_in][]=to an id outside the endpoint's scope still renders that record's label in the tag and the combobox token. This is the same position abelongs_tofilter's defaultklass.alloptions take, and it discloses one label at a time; treat it as a reason not to rely on a filter's option scoping to keep an association's names or e-mail addresses private.
min_chars: and text: pass through to l_ui_combobox alongside url:. The write-side combobox options (create:, create_name:, reorder:) don't: a filter picks among values that already exist.
Overriding the inference
Pass an options hash per attribute to override what's inferred:
filters :created_at,
status: { collection: %w[draft live] }, # override the select options
user: { multiple: false, # single-choice (user_id_eq) instead of multi
collection: -> { User.active } }, # scope the options per request
title: { as: :string, label: "Headline" } # force a "contains" text filter
Recognised keys:
as:— force a control type (:select,:combobox,:boolean,:string,:range,:date_range). Naming:selector:comboboxalso pins the control against the option-count switch.collection:— options for a select: an array of values, an array of[label, value]pairs, or a callable resolved per request (returning either form, or records). Acollection:on a plain string column promotes it to a select.multiple:—true(the default for select-type filters) filters via the_inpredicate;falsegives a single-choice_eq.url:,min_chars:,text:— remote options, fetched from an endpoint as the user types.label:— override the filter's name (defaults tohuman_attribute_name, so i18n flows through).pinned:— always show the tag. Pinned tags never appear in the add-filter menu and have no remove ✕ (their popover's Clear resets the value; the tag stays).default:— value applied when the request carries none of the filter's params: a scalar,{ from:, to: }for ranges, an array formultiple:, or a callable resolved per request (e.g.-> { { from: 7.days.ago.to_date } }).
Pinned tags and defaults
filters :created_at,
status: { pinned: true, default: Post.statuses[:published] },
user: { pinned: true }
Pinned filters render as tags from the start, so the common ones are one click away instead of two; the Add filter button only renders while there are unpinned filters left to add (pin everything and it disappears). A default: applies whenever the request carries no state for that filter — the tag shows it as active and every link and form round-trips it explicitly from then on. Clearing a defaulted filter writes an explicit blank (q[status_eq]=) rather than dropping the param, so the default doesn't immediately re-apply.
The filter bar renders inside the index's Turbo frame between the search box and the table; eject the views (rails g layered:resource:views) to customise placement — the bar is the _filters partial, and each control is _filter_control.
Column rendering
Each column on the index table is rendered through a partial. By default the gem picks one based on the model's column type (text for strings, datetime for timestamps, etc.), but you can pin a column to a specific renderer with as::
columns [
{ attribute: :title, primary: true },
{ attribute: :status, as: :badge, variants: { published: :success, draft: :warning } },
{ attribute: :priority, as: :badge, rounded: true },
{ attribute: :created_at, as: :datetime, format: "%Y-%m-%d" },
{ attribute: :pinned, as: :boolean, true_label: "Yes", false_label: "No" }
]
The built-in column types are :text, :datetime, :badge, and :boolean. Lookup order is per-resource → host-wide → gem default, so any partial you place at app/views/layered/<resource>/columns/_<type>.html.erb overrides the gem's built-in for that resource only, and one at app/views/layered/resource/columns/_<type>.html.erb overrides it host-wide.
Use the column generator to eject a built-in or scaffold a new one:
rails g layered:resource:column badge # eject the built-in host-wide
rails g layered:resource:column badge posts # eject scoped to PostResource
rails g layered:resource:column priority_badge # scaffold a brand-new type
A custom partial receives record, value, and options (the column hash) as locals - read keys like :variants or :format straight off options.
Sortable headers
A column header renders a sort link only when the attribute is sortable. This defaults to true for real DB columns and false for anything else - virtual attributes and delegated association values - because Ransack can't sort those without the associated model allowlisting the underlying field, and the sort link would 500 when clicked. Set sortable: true on the column to opt back in; you're then responsible for that model's ransackable_attributes (see Associations).
columns [
{ attribute: :title, primary: true },
{ attribute: :user_name, label: "Author", sortable: true }
]
Index introduction
To render an introduction above the search area on a resource's index page, drop a partial at app/views/layered/<resource>/_introduction.html.erb. It's rendered when present and skipped otherwise — no DSL or configuration needed.
<%# app/views/layered/posts/_introduction.html.erb %>
<div class="l-ui-mt-4">
<p>Browse the latest posts. Use search and sort to find what you're looking for.</p>
</div>
The partial sits inside the resource's view directory, so it follows the same per-resource override path as ejected views and column partials.
Record pickers
A field naming a belongs_to's foreign key is a record picker, so it renders as a single-select combobox - a type-ahead input whose selection becomes a removable token - over the associated records, rather than as the number the column happens to hold:
fields [
{ attribute: :title },
{ attribute: :user_id, label: "Author" } # belongs_to :user -> author picker
]
The default options are klass.all, labelled the same way a belongs_to filter's are (the first present of name/title/label/email, else "User #12"), and resolved per request rather than once at boot. The picker posts the plain foreign key (post[user_id]), so nothing else in the write path changes.
Two things follow from the association rather than the column:
- Required. A
belongs_tovalidates the presence of the association, not of the foreign key, so the picker's required flag comes from the association's ownoptional:(resolved as ActiveRecord resolves it, viabelongs_to_required_by_default) instead of from a presence validator on the column. - Polymorphic associations are skipped. There is no single class whose records could fill the picker, so the field falls back to its column type.
Override with as: to opt out of the control entirely, or collection: to keep it and replace the options - to scope them, order them, or label them by a particular resource:
fields [
{ attribute: :user_id, as: :select, collection: -> { User.pluck(:name, :id) } }, # plain <select>
{ attribute: :editor_id, collection: -> { User.editors.map { |u| [UserResource.record_label(u), u.id] } } }
]
Note that the default labelling deliberately does not consult the associated model's own resource for its label_attribute: a model can have several resources (a plain one and an admin variant, say), so there is no single resource to ask. Name one in a collection: when you want its labelling.
Every other combobox option - multiple:, url:, min_chars:, create:/create_name:, reorder:, text: - passes straight through to l_ui_combobox.
Strong parameters for nested or array fields
By default each entry in fields is permitted as a scalar. To allow an array (e.g. has_many_attached) or a nested hash (e.g. accepts_nested_attributes_for), set permit: on the field:
fields [
{ attribute: :title },
{ attribute: :documents, as: :file, permit: [] }, # array of files
{ attribute: :address_attributes, permit: [:street, :city, :zip] } # nested hash
]
permit: [] produces params.permit(documents: []); permit: [:street, :city] produces params.permit(address_attributes: [:street, :city]).
Custom member and collection routes
To add non-CRUD actions (e.g. POST /posts/:id/approve, POST /posts/bulk_archive), pass a block to layered_resources with the same member/collection DSL Rails' resources uses. A block requires controller: because the action implementation has to live somewhere - generate a controller subclass with rails g layered:resource:controller posts and point the route at it:
layered_resources :posts, controller: "posts" do
member do
post :approve
end
collection do
get :bulk_archive
post :bulk_destroy
end
end
class PostsController < Layered::Resource::ResourcesController
def approve
@record.update!(approved: true)
redirect_to layered_member_path(@record), notice: "Post approved"
end
def bulk_archive
# render a confirmation page, run the archive, etc.
end
end
Inside the controller, @resource is the resource class and the standard Rails before_actions from ApplicationController (e.g. authenticate_user!) still apply.
Auto-loaded @record (member actions only). Custom member actions get @record set from params[:id] via @resource.scope(self).find before the action runs. This does not extend to collection actions — those have no :id and @record stays nil, so do any lookups yourself. Opt a member action out of the auto-load with skip_before_action :load_layered_member_record, only: [:foo] if it shouldn't 404 on a missing record (or shouldn't pay for the lookup).
Path helpers available inside actions:
layered_collection_path— index path for the current resource. Raises if:indexisn't routed.layered_member_path(record)— show/update/destroy path for the current resource. Raises if no member route is registered.layered_routes.<helper>_path(...)— any registered route, with parent params filled in from the current request.
Variants via inheritance
For variants that warrant their own URL - typically a separate admin area - declare a subclass and register it on its own route. The subclass inherits model, columns, fields, search_fields, search_placeholder, default_sort, per_page, and root_breadcrumb from the parent and overrides only what differs:
# app/layered_resources/admin/post_resource.rb
class Admin::PostResource < PostResource
columns [
{ attribute: :title, primary: true },
{ attribute: :status },
{ attribute: :author_name, label: "Author" },
{ attribute: :created_at, label: "Published" }
]
fields [
{ attribute: :title },
{ attribute: :body, as: :text },
{ attribute: :status },
{ attribute: :pinned, as: :checkbox }
]
end
# config/routes.rb
layered_resources :posts
namespace :admin do
layered_resources :posts, resource: "Admin::PostResource"
end
search_fields and model aren't redeclared - they're inherited from PostResource.
Authentication
Layered::Resource::ResourcesController inherits from your app's ApplicationController, so any before_action you've declared there (e.g. Devise's authenticate_user!) already protects every layered resource request.
Engines: route to your engine's ApplicationController
For an engine with its own ApplicationController (running its own authorize/authenticate chain), define a sibling controller and include the gem's concern:
# app/controllers/layered/assistant/resources_controller.rb
class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
include Layered::Resource::Controller
end
Then pass namespace: so layered_resources derives both the resource class and the controller from one option:
# config/routes.rb (or your engine's routes)
scope path: "/assistant", module: "layered/assistant" do
layered_resources :skills, namespace: "Layered::Assistant"
end
This resolves to resource: "Layered::Assistant::SkillResource" and routes to Layered::Assistant::ResourcesController automatically — no per-route resource:/controller: plumbing.
namespace: is explicit-only. Avoid wrapping in a namespace :foo block: Rails composes URL helpers differently inside one (e.g. foo_new_post_path instead of new_foo_post_path), and the gem-shipped views call the latter form. Use scope path:/module: as above to get the path/module without the :as prefix.
Flash messages
Flash strings come from i18n. The gem ships English defaults under layered.resource.flash.*:
| Key | Trigger |
|---|---|
created |
create succeeded |
updated |
update succeeded |
deleted |
destroy succeeded |
not_deleted |
record.destroy returned false |
dependent_records |
destroy rescued ActiveRecord::InvalidForeignKey or ActiveRecord::DeleteRestrictionError (the gem catches these so dependent-record violations redirect with a flash instead of 500ing) |
Override per-locale by adding the same keys in your host app's config/locales/<lang>.yml:
en:
layered:
resource:
flash:
created: "%{model} added successfully"
dependent_records: "Can't delete %{model} — it still has linked records."
The %{model} interpolation is model.model_name.human, so it picks up any activerecord.models.<key> translations you've already defined.
Show is intentionally minimal
The default show view is a heading with Edit and Delete buttons - it
doesn't iterate over columns because columns are designed for table cells
and have no per-user gating. If a column is configured for the index it
would otherwise leak in full on show. When you want a real detail page,
eject views with rails g layered:resource:views <name> and write the show
template against @record directly.
Escape hatching
The gem is designed so you can start fully managed and progressively take over control if you outgrow the defaults.
Override the scope or redirect target directly in the resource class:
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
controller.current_team.posts
end
def self.build_record(controller)
scope(controller).build(author: controller.current_user)
end
def self.after_save_path(controller, record)
controller.main_app.post_path(record)
end
end
Eject views when you need full control over presentation:
rails g layered:resource:views posts
This copies the gem's actual index, show, new, and edit templates into app/views/layered/posts/ - fully populated, working ERB you can edit immediately. Delete any of them to fall back to the gem default; keep the rest to override only what you need.
Override the controller. Use the generator to create one in the right place:
rails g layered:resource:controller posts
This gives you a controller that inherits from the base - override any of the standard CRUD actions and call super when you only want to tweak behaviour.
If you outgrow the gem entirely, drop the inheritance and write a plain Rails controller:
class PostsController < ApplicationController
def index
@posts = Post.all
end
# ...
end
# swap the route
resources :posts
Documentation
A live demo of the dummy app is deployed at https://layered-resource-rails.layered.ai.
You can also run it locally to explore:
git clone https://github.com/layered-ai-public/layered-resource-rails.git
cd layered-resource-rails
bundle install
cd test/dummy && bin/rails db:setup && bin/dev
Deploying the dummy app
The dummy app can be deployed with Kamal. Set the required environment variables and deploy from test/dummy:
cd test/dummy
export KAMAL_DEPLOY_IP=<server-ip>
export KAMAL_DEPLOY_DOMAIN=<domain>
export KAMAL_SSH_KEY=<path-to-ssh-key>
kamal deploy
KAMAL_DEPLOY_DOMAIN defaults to layered-resource-rails.layered.ai. SECRET_KEY_BASE is read from test/dummy/.kamal/secrets, which is gitignored - create it locally before the first deploy.
Contributing
This project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests for now to stay focused on getting the foundations right. Thank you for your patience and interest. See CLA.md for the full policy.
License
Released under the Apache 2.0 License.
Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See NOTICE for attribution details.
Trademarks
The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See TRADEMARK.md for the full policy.