| name | layered-resource-rails |
| description | Installs, configures, and builds with the layered-resource-rails gem - a Rails 8+ engine providing convention-over-configuration CRUD scaffolding with search, sort, and pagination. Use when adding layered-resource-rails to a Rails app, defining resource classes, mounting `layered_resources` routes, ejecting views or controllers, or troubleshooting setup. |
| license | Apache-2.0 |
| compatibility | Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.9, ransack ~> 4.0, pagy ~> 43.2 |
| metadata | {"author":"layered.ai","version":"1.0","source":"https://github.com/layered-ai-public/layered-resource-rails"} |
layered-resource-rails
A Rails 8+ engine that scaffolds CRUD UIs from a single resource class and a single route. Built on top of layered-ui-rails, Ransack, and Pagy.
Installation
bundle add layered-resource-rails
The gem depends on layered-ui-rails for its UI. If the host app hasn't installed it yet, run its install generator first:
bin/rails generate layered:ui:install
Quick start
The fastest path is the scaffold generator. It invokes Rails' built-in model generator, writes a resource class, and appends a route:
rails g layered:resource:scaffold post title:string body:text
This produces:
db/migrate/<timestamp>_create_posts.rb and app/models/post.rb (via rails g model)
app/layered_resources/post_resource.rb with columns and fields derived from the attributes
layered_resources :posts appended to config/routes.rb
Useful flags:
--skip-model - the model already exists
--actions index show - emits only: [:index, :show]
--except destroy - emits except: [:destroy]
--controller - also eject a controller and wire it into the route
--views - also eject the templates upfront
Views are intentionally not generated by default - the gem's defaults render until ejected.
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. It writes app/layered_resources/post_resource.rb and appends layered_resources :posts to config/routes.rb. Pass --skip-route to skip the route line.
What you get
For layered_resources :posts with all actions enabled:
| Route | Action |
|---|
GET /posts | index |
GET /posts/:id | show |
GET /posts/new | new |
POST /posts | create |
GET /posts/:id/edit | edit |
PATCH /posts/:id | update |
DELETE /posts/:id | destroy |
The index table's primary column links to each record's edit page automatically (or its show page for read-only resources that have :show but not :edit).
The default show view is intentionally a blank canvas - the gem does not auto-render an attribute list (generated detail pages are low-value and always need customizing). Since the index links titles to edit, :show is only worth keeping if you'll build a real detail view (eject it with rails g layered:resource:views and fill in the template). Otherwise advise except: [:show] to drop the route.
@page_title is set automatically per action: pluralized model name on index, "New <Model>" on new, the record's primary column value on show, and "Edit <record label>" on edit. The layered-ui-rails layout reads it for <title>.
Resource DSL
Resource classes live in app/layered_resources/ and inherit from Layered::Resource::Base:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :status },
{ attribute: :created_at, label: "Published" }
]
fields [
{ attribute: :title },
{ attribute: :body, as: :text },
{ attribute: :status }
]
search_fields [:title, :body]
default_sort attribute: :created_at, direction: :desc
per_page 25
end
| Method | Purpose |
|---|
model Post | The ActiveRecord class this resource manages |
columns [...] | Index table columns. Each entry is { attribute:, label:, primary:, link:, render: } |
fields [...] | Form fields for new/edit. Omit to disable CRUD forms |
search_fields [...] | Ransack attributes the index search box matches against. Association-walking entries like :user_name (for belongs_to :user + users.name) join into the association |
search_placeholder "..." | Replaces the search box placeholder. Default derives from search_fields via human_attribute_name, so activerecord.attributes.<model>.<attr> i18n renames flow through (association walks resolve each half against its own model) |
filters :a, :b, c: {...} | Structured filter controls on the index — an "Add filter" popover plus removable tags. Control + Ransack predicate inferred per column; trailing hash overrides per attribute. See Filters |
default_sort attribute:, direction: | Default sort order for the index |
per_page n | Pagination size (default 15) |
root_breadcrumb "Home", "/" | Static first crumb in the breadcrumb trail (e.g. back to the host app's dashboard). Without it, top-level resources render no trail; nested routes prepend it to the derived parent trail |
Column options
primary: true - marks the cell that links to the record's edit (or show) page (defaults to first column)
label: "Custom" - overrides the humanised attribute name
link: :route_key - wraps the column's rendered value in a link to a nested route (e.g. :users_posts); composes with as: (pair with as: :badge for a badge link)
render: ->(record) { ... } - custom cell renderer. The proc is a plain Ruby closure invoked with .call (not instance_exec), so self inside it is the lexical scope where you wrote it - the resource class body, which has no view helpers. To use a view helper (l_ui_format_datetime, link_to, tag.*, etc.), take the view context as a second arg: render: ->(record, view) { view.l_ui_format_datetime(record.created_at) }. Arity >= 2 (or variadic/optional, e.g. ->(record, view = nil)) trips the view-injection branch; an arity-1 proc that calls a view helper raises NoMethodError ... for class YourResource. Before reaching for a proc at all: the default renderer already strftime-formats datetime columns (and dispatches as: partials), so a plain { attribute: :created_at, label: "Created" } renders the timestamp readably with no proc needed.
Field types
Field as: follows Rails' form_with field helpers - :text, :checkbox, :date, :datetime, :select, etc. A field is automatically marked required if its model has an unconditional presence validator on that attribute.
Filters
filters declares structured index controls complementing the single free-text search_fields box. The UI: an Add filter button opens a popover listing the declared filters; picking one adds it as an unset tag at the end of the row (the f[] param tracks added tags and their order for as long as they're shown) with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Single-choice filters apply instantly via links; ranges/text/multi-selects apply via a small GET form. Every filter is a Ransack predicate in the URL, so filters compose with search, sort, and pagination — the search form and each filter form round-trip the other q params (and f[] entries) as hidden fields (no JavaScript; the one-shot fo param marks which tag's popover renders open).
filters :status,
:featured,
:created_at,
:comments_count,
:user
Inference by column type: enum/belongs_to -> multi-select (_in — checkbox list + Apply; multiple: false gives a single-choice _eq select of instant-apply links); boolean -> Yes/No (_eq); date/datetime -> date range (_gteq+_lteq); numeric -> number range; string/text -> "contains" (_cont), or a multi-select when a collection: is given. A belongs_to filter keys on the foreign key (user_id) so it never joins — no association-walk setup needed; its default options are klass.all labelled by the first present of name/title/label/email.
Override per attribute with a trailing hash: as: (force control type: :select, :boolean, :string, :range, :date_range), collection: (select options — array, [label, value] pairs, or a callable resolved per request, returning either form or records), multiple: (defaults to true for select-types; false gives single-choice _eq), label:, pinned: (tag always shown — never in the add menu, no remove ✕; Clear resets the value but the tag stays), default: (value applied when the request has no state for the filter — scalar, { from:, to: } for ranges, array for multiple:, or a callable). The add-filter button only renders while unpinned filters remain. Clearing a defaulted filter writes an explicit blank (q[status_eq]=) so the default doesn't re-apply. Filtered attributes are added to the resource's Ransack allowlist; un-shown/un-searched/un-filtered attributes stay un-queryable and stray q[...] params are ignored, not raised.
The bar renders inside the index Turbo frame between search box and table, built from l_ui_popover and the _filters/_filter_control partials — eject with rails g layered:resource:views to customise.
Route DSL
Mount in config/routes.rb:
layered_resources :posts
layered_resources :posts, only: [:index]
layered_resources :posts, except: [:destroy]
layered_resources :posts, controller: "posts"
layered_resources :posts, resource: "Admin::PostResource"
layered_resources :posts, namespace: "Admin"
Incoherent only: combos raise at boot time - e.g. :new without :create, or :edit without :update.
Nested routes
layered_resources :users
resources :users, only: [] do
layered_resources :posts
end
scope "users/:user_id" do
layered_resources :posts
end
Helper names follow the standard Rails nested-resources convention, so polymorphic_path([@user, :posts]), link_to "Edit", [@user, @post], url_for([@user, @post, :comments]) etc. all resolve. The link: column option uses the same name (link: :user_posts, link: :user_post_comments).
Never add as: to a surrounding scope. layered_resources composes its own helper names from the path segments (scope path: "manage" → manage_posts_path, new_manage_post_path, …), so as: is unnecessary. A value matching the path is absorbed silently; a disagreeing one (as: "admin") is ignored with a boot-time warning.
Resolve the parent in scope:
class PostResource < Layered::Resource::Base
model Post
def self.scope(controller)
if controller.params[:user_id].present?
User.find(controller.params[:user_id]).posts
else
Post.all
end
end
end
Override points
Override these class methods on the resource (not the controller) to change behaviour:
class PostResource < Layered::Resource::Base
model Post
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
Note: when current_user (or whichever accessor a hand-rolled scope reads) is nil, return model.none rather than model.all - otherwise unauthenticated requests see the full table. The owned_by shorthand below raises loudly instead, surfacing missing auth wiring.
Ownership shorthand
owned_by collapses the two most common scope/build_record overrides into one declaration:
class QuoteResource < Layered::Resource::Base
model Quote
owned_by :user
end
It rewires scope(controller) = model.where(association => controller.public_send(via)) and assigns the owner in build_record. Pure data filter - it does not enforce per-action authorisation.
When via returns nil, owned_by raises Layered::Resource::MissingOwnerError so a missing before_action :authenticate_user! surfaces immediately instead of every page silently 404ing. Pass allow_nil: true to opt into public-with-scope behaviour (returns model.none and assigns nil on create):
owned_by :user, allow_nil: true
When combined with use_pundit, the nil-owner check is skipped — Pundit's policy gate (policy.create?) is the authoritative auth check.
Authorisation (Pundit)
The gem ships first-class Pundit support as opt-in. Add use_pundit to a resource:
class PostResource < Layered::Resource::Base
model Post
use_pundit
end
When enabled:
scope(controller) defaults to the controller's policy_scope(model) helper, so apps that override pundit_user (e.g. current_account) get the same identity used by authorize.
- The controller calls
authorize(@record) after loading a member record (show/edit/update/destroy and any custom member action).
- The
New/Edit/Delete actions in the default views (inline buttons on show, a per-row actions popover menu on index) hide for users whose policy denies the action - the views call a resource_can?(action, record = nil) helper that ANDs the route-exposure flag with policy(record).<action>?.
owned_by composes with use_pundit: Pundit's Policy::Scope#resolve wins for the read filter; owned_by still drives owner assignment on create. Stack both when needed.
For CanCan, plain POROs, or bespoke policies, override scope directly (and add before_action :authorize_* callbacks in an ejected controller). use_pundit is just a convenience for the Pundit case.
In ejected views, prefer resource_can?(:update, @record) over the raw @resource_can_update ivar when you want per-record policy gating; the ivar still works for route-exposure-only checks.
Inheritance / variants
Subclasses inherit model, columns, fields, search_fields, search_placeholder, default_sort, per_page, and root_breadcrumb. Override only what differs:
class Admin::PostResource < PostResource
columns [
{ attribute: :title, primary: true },
{ attribute: :author_name, label: "Author" },
{ attribute: :pinned }
]
end
layered_resources :posts
namespace :admin do
layered_resources :posts, resource: "Admin::PostResource"
end
Index introduction
Drop a partial at app/views/layered/<resource>/_introduction.html.erb to render content above the search area on that resource's index page. The partial is rendered when present and skipped otherwise — no DSL, no configuration. It uses the same per-resource view path as ejected templates and column overrides.
Ejection
Take over presentation or controller logic without giving up the rest:
rails g layered:resource:views posts
rails g layered:resource:controller posts
The controller's _prefixes is overridden so app/views/layered/<plural>/ overrides win automatically - no extra wiring. Delete any individual ejected template to fall back to the gem default.
To outgrow the gem entirely: drop the inheritance, write a plain Rails controller, swap layered_resources :posts for resources :posts in routes.
Authentication
Layered::Resource::ResourcesController inherits from the host app's ApplicationController, so any before_action declared there (e.g. Devise's authenticate_user!) already protects every layered resource request - no extra configuration needed.
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:
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:
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.
Flash messages (i18n)
Flash strings live under layered.resource.flash.* in config/locales/en.yml:
| Key | Trigger |
|---|
created / updated / deleted | Successful create/update/destroy |
not_deleted | record.destroy returned false |
dependent_records | destroy rescued ActiveRecord::InvalidForeignKey or ActiveRecord::DeleteRestrictionError |
Override per-locale in the host app's config/locales/<lang>.yml. The %{model} interpolation is the humanised model name.
destroy rescues FK and restrict-dependent violations and redirects to the index with the dependent_records flash instead of returning a 500.
Show is intentionally minimal
The default show view renders the primary column as a heading with Edit and Delete buttons - nothing else. It does not iterate over columns (those are designed for index table cells, with no per-user gating, and would leak everything declared for the index). For a real detail page, eject with rails g layered:resource:views <name> and edit the show template directly against @record.
Associations
Resources are independent - each model gets its own resource class. To surface association data on an index, expose a method on the model and reference it as a virtual column attribute:
class Post < ApplicationRecord
belongs_to :user
delegate :name, to: :user, prefix: true, allow_nil: true
end
columns [
{ attribute: :title, primary: true },
{ attribute: :user_name, label: "Author" }
]
Counts of nested resources
To show how many children a record has, back the count with a Rails counter cache, not a virtual record.children.size column. A virtual size column issues a COUNT query per row (an N+1 on the index) and can't be sorted or searched; a counter-cache column reads off the parent row in the index's single query and sorts via q[s]=<count> asc like any other attribute.
Add counter_cache: true to the child's belongs_to and a backing integer column on the parent, then point the column at that real attribute. Render the count as a rounded badge so it reads as a count rather than a value:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
columns [
{ attribute: :title, primary: true },
{ attribute: :comments_count, label: "Comments", as: :badge, rounded: true, link: :post_comments }
]
as: :badge, rounded: true gives the rounded count pill; link: (optional) makes it link to the children's nested index. If the parent doesn't already carry a counter-cache column, add the migration and counter_cache: true rather than reaching for .size.
To make an association searchable, add a Ransack-walk-shaped entry to search_fields - e.g. search_fields [:title, :user_name] resolves :user_name against belongs_to :user + users.name and joins into the association. The gem scopes the Ransack allowlists per resource: it only responds when the resource class is the auth object (on both the parent and the associated model), so host-app Ransack config is preserved. A walked search field is also sortable (q[s]=user_name asc orders by users.name) because Ransack derives its sort allowlist from the search allowlist. Cross-model sort/filter on associations not declared in search_fields remains off; to opt in, define ransackable_associations on the parent model yourself and allowlist the attributes on the child model - the gem detects the host-defined override and unions with it. The search box placeholder labels a walk as " " via each model's human_attribute_name - translate activerecord.attributes.<model>.<attr> to rename a half, or set search_placeholder on the resource to replace the whole string.
Common issues
NoMethodError: undefined method 'l_ui_table' - the host app hasn't installed layered-ui-rails. Run bin/rails generate layered:ui:install.
- Search/sort returns empty - the attribute isn't in
search_fields, or Ransack's ransackable_attributes on the model excludes it. The resource patches Ransack only when itself is the auth object; verify nothing in the host app removes the attribute unconditionally.
only: validation error at boot - :new requires :create, :edit requires :update. Adjust the action list.
- Ejected view not picked up - the controller looks under
app/views/layered/<plural_name>/, where <plural_name> is the symbol passed to layered_resources (ignoring Rails namespaces). The generator mirrors this; if you've moved files manually, match that path.
Further reference