Skip to content

layered-ai-public/layered-resource-rails

Repository files navigation

layered-resource-rails

License: Apache 2.0 Website GitHub Discord YouTube X LinkedIn

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

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.

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

  root_breadcrumb "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.

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:

  1. scope(controller) returns Quote.where(user: controller.current_user), so the index, show, edit, etc. only see records the user owns.
  2. build_record(controller) assigns the owner on new records, so create stamps user_id automatically.
  3. When controller.current_user is nil, owned_by raises Layered::Resource::MissingOwnerError so a missing authenticate_user! surfaces immediately instead of every page silently 404ing. Pass allow_nil: true for genuinely public-with-scope behaviour (returns Quote.none and assigns nil on create). With use_pundit, the policy gate (policy.create?) decides — owned_by lets 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's policy_scope(model) helper - the index reads through Policy::Scope#resolve. This routes through Pundit's pundit_user, so apps that authorize as current_account (or any other identity) get the same context here as in authorize calls.
  • Every action that loads or builds a record (new, create, show, edit, update, destroy, plus any custom member action declared in a layered_resources block) calls authorize(@record) automatically. Pundit raises Pundit::NotAuthorizedError on denial; handle it in your ApplicationController as you would for any Pundit-backed app.
  • Action buttons hide automatically: the New link on the index, the Edit/Delete items in the index row's actions popover, and the Edit/Delete buttons on the show page check policy(record).new?/update?/destroy? so users only see actions they can perform.

verify_authorized / verify_policy_scoped: if your ApplicationController runs Pundit's after_action :verify_authorized (or :verify_policy_scoped) globally, layered resources that don't opt into use_pundit will raise AuthorizationNotPerformed because the controller never calls authorize for 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.id rather than record.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), @record is a Question and the gem calls QuestionPolicy#submit_answer? — even if the action ultimately creates an Answer. 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 surrounding scope. layered_resources derives its own helper names from the path segments (scope path: "manage"manage_posts_path, new_manage_post_path, …), so an as: 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 single-choice selects apply instantly on click; multi-selects, 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: a checkbox list with an Apply button, filtering via the _in predicate. Pass multiple: false for a single-choice select — those render as a list of links that apply instantly on click (booleans always do).

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.

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, :boolean, :string, :range, :date_range).
  • 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). A collection: on a plain string column promotes it to a select.
  • multiple:true (the default for select-type filters) renders checkboxes applying via the _in predicate; false renders instant-apply single-choice links via _eq.
  • label: — override the filter's name (defaults to human_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 for multiple:, 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.

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.

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 :index isn'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

Run the included dummy app 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

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages