LaunchKit
← All posts
· 11 min read · by The LaunchKit team · 0 views

Ruby on Rails for beginners, and the tutorial that no longer runs

The hard part of a first week in Rails is not Rails. It is that the tutorial in one window and the application in the other are describing different frameworks, and nothing tells you which lines have moved. A page written for Rails 4 and a page written last month look identical: same syntax highlighting, same confident tone, no date anywhere near the code block. So the first hour goes on reconciliation rather than on learning, and the failure mode is believing the tutorial and concluding you have broken something.

Everything below was run against a fresh application generated with rails _8.1.3.1_ new blog, running Rails 8.1.4 on Ruby 4.0.5, SQLite 3.53.2 and Puma 8.0.2, on an Apple M2 Max under macOS arm64-darwin25. The outputs are pasted out of bin/rails test, bin/rails runner and log/development.log. What that application contains before anybody writes a line is counted separately in Rails for an MVP, counted; which course to follow is Is Ruby on Rails hard to learn. This page is the narrower question of which lines still execute.

Four lines from an older tutorial that now raise

Four idioms account for most of the confusion, because they were in every Rails tutorial for years and they are gone. update_attributes survived longest: it is a deprecated alias at activerecord-6.0.6.1/lib/active_record/persistence.rb:625, and the string does not appear anywhere in activerecord-6.1.7.10. attr_accessible, before_filter and Model.scoped went earlier than that, early enough that none of the three appears in the oldest releases still installed on this machine, activerecord-5.2.8.1 and actionpack-5.2.8.1. All four now raise NoMethodError, which is the good case: the error names the missing method and is searchable.

The test file below is the record. It lives at test/models/old_tutorial_idioms_test.rb in the generated application, and its job is to assert what raises and what does not, so that the claims on this page are executable rather than remembered.

require "test_helper"

# Every line in this file is a line a Rails tutorial still prints somewhere.
# The test records which of them run on this application and which do not.
class OldTutorialIdiomsTest < ActiveSupport::TestCase
  setup { @post = posts(:one) }

  test "update_attributes is gone and update is what replaced it" do
    error = assert_raises(NoMethodError) { @post.update_attributes(title: "x") }
    assert_match "undefined method 'update_attributes'", error.message

    assert @post.update(title: "x")
  end

  test "attr_accessible is gone, strong parameters replaced it in the controller" do
    assert_raises(NoMethodError) do
      Class.new(ActiveRecord::Base) { attr_accessible :title }
    end
  end

  test "before_filter is gone and before_action is the same thing renamed" do
    assert_raises(NoMethodError) do
      Class.new(ActionController::Base) { before_filter :authenticate }
    end

    assert Class.new(ActionController::Base) { before_action :authenticate }
  end

  test "Post.scoped is gone, Post.all returns the relation now" do
    assert_raises(NoMethodError) { Post.scoped }
    assert_kind_of ActiveRecord::Relation, Post.all
  end
end

The exact messages, printed by bin/rails runner on the same application:

post.update_attributes(title:)                NoMethodError: undefined method 'update_attributes' for an instance of Post
Post.scoped                                   NoMethodError: undefined method 'scoped' for class Post
ActiveRecord::Base.attr_accessible            NoMethodError: undefined method 'attr_accessible' for class #<Class:0x0000000126c9bfe0>
ActionController::Base.before_filter          NoMethodError: undefined method 'before_filter' for class #<Class:0x0000000127199420>

One older idiom behaves worse than those four, and it is the one worth memorising. Post.find(:all) is Rails 2 for "give me every row". It does not raise NoMethodError, because find still exists and still takes an id, so Rails does the only thing it can with a symbol:

Post.find(:all)                               ActiveRecord::RecordNotFound: Couldn't find Post with 'id'=:all

A beginner reads RecordNotFound as a data problem, goes and checks the database, finds twenty rows sitting there and loses the afternoon. The current line is Post.all.

Three lines that look just as old and still work

Age is not the test, which is the part that makes dating a tutorial hard. Three idioms that look exactly as antique as the four above are still supported on Rails 8.1.4, and rewriting them because they look old is wasted effort.

update_attribute, singular, is alive and still skips validations, which is the only reason to be careful with it. With validates :title, presence: true declared on the model, the two methods disagree, and the test says so:

# Post now declares: validates :title, presence: true
test "update_attribute, singular, is still here and still skips the validation" do
  assert_not @post.update(title: "")
  assert_equal [ "Title can't be blank" ], @post.errors.full_messages

  assert @post.update_attribute(:title, "")
  assert_equal "", @post.reload.title
end

Post.find_by_title("...") is a dynamic finder, a thing frequently described on the internet as removed, and it answers. params.require(:post).permit(:title) works beside the newer params.expect(post: [ :title ]) that the Rails 8.1 scaffold actually writes, and there is no deprecation on the older form.

test "params.require().permit() still works beside params.expect()" do
  params = ActionController::Parameters.new(post: { title: "t", admin: true })

  assert_equal({ "title" => "t" }, params.require(:post).permit(:title).to_h)
  assert_equal({ "title" => "t" }, params.expect(post: [ :title ]).to_h)
end

Both forms drop admin without a word, which is the behaviour to understand rather than the syntax; what expect does differently when a key is missing is in A Rails REST API, from rails new to the errors nobody shows you.

The one genuine trap in this group is :unprocessable_entity. It still resolves to 422, so a controller copied from an older tutorial renders correctly, and it prints a warning to stderr while doing it:

warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.

Rack::Utils.status_code(:unprocessable_entity) and Rack::Utils.status_code(:unprocessable_content) both returned 422 here under Rack 3.2.7. The scaffold writes the second one. Why that rename happened, and what else moved with it, is in Ruby on Rails and Hotwire.

There is a fourth change in this family that no error message announces at all. belongs_to is required by default, switched on by load_defaults at railties-8.1.4/lib/rails/application/configuration.rb:121, under when "5.0". An association a tutorial leaves unset therefore fails validation now instead of saving a null:

Session.create! with no user                  ActiveRecord::RecordInvalid: Validation failed: User must exist

How to date a Rails tutorial in ten seconds

Three paths and two constants settle the age of a tutorial without reading its prose, checked on the generated application with File.exist? and defined?:

Tell In an application generated today
app/assets/javascripts false
config/secrets.yml false
app/assets/config/manifest.js false
defined?(Turbolinks) nil
defined?(Sprockets) nil
Rails.application.assets.class Propshaft::Assembly
Dir.children("app/assets") ["images", "stylesheets"]
Dir.children("app/javascript") ["application.js", "controllers"]

A tutorial that tells you to edit app/assets/javascripts/application.js, or to add a gem called turbolinks, or to put a key in config/secrets.yml, is describing an application you do not have. That does not make the rest of it wrong, and throwing the whole page away over one stale path is an overcorrection: the Active Record chapter of a 2018 tutorial is mostly still accurate, because Active Record barely moved. The asset and configuration chapters are the ones that rotted.

bin/rake db:migrate is not on that list on purpose. Tutorials from before Rails 5 write rake where current ones write bin/rails, and bin/rake db:version answered Current version: 20260927201849 on this application, so rake dates a page without breaking anything.

The three errors of a first hour, in full

Three errors arrive in the first hour of a Rails application for reasons that have nothing to do with the code being wrong, and all three are quoted here in full because a truncated error is unsearchable. The first one shows up the moment a generated model is used before its migration has run, and it is the most helpful error message in the framework:

ActiveRecord::PendingMigrationError (

Migrations are pending. To resolve this issue, run:

        bin/rails db:migrate

You have 1 pending migration:

db/migrate/20260927201849_create_comments.rb

The response is a 500 in development, and it is a 500 on routes that have nothing to do with the table in question: GET /postz, which is nothing but a typo, also answered 500 while that migration was pending. That is how a typo and a forgotten migration turn into one confusing morning. After bin/rails db:migrate, the same typo gives the error it should have given all along:

ActionController::RoutingError (No route matches [GET] "/postz"):

The third one waits until the first deploy. .gitignore ends with the line /config/*.key, and the file that pattern catches is config/master.key, the one that decrypts config/credentials.yml.enc. So the single file production cannot boot without is the single file git was told never to carry. Moving config/master.key aside and starting the application in production reproduces it exactly:

railties-8.1.4/lib/rails/application/configuration.rb:543:in 'Rails::Application::Configuration#secret_key_base=': Missing `secret_key_base` for 'production' environment, set this string with `bin/rails credentials:edit` (ArgumentError)

Nothing is corrupted when that happens and nothing needs regenerating. The key is on the laptop that ran rails new, and it goes to the server as RAILS_MASTER_KEY.

The dead end: the Rails you generated with is not the Rails you are running

rails _8.1.3.1_ new blog pins the generator and does not pin the application, which cost real time here before it became obvious. The Gemfile that command writes is:

gem "rails", "~> 8.1.3", ">= 8.1.3.1"

Bundler then resolves the newest release inside that range, and Gemfile.lock says rails (8.1.4). bin/rails -v in the generated application answers Rails 8.1.4, not the 8.1.3.1 that was asked for. For a beginner this matters for one reason only: when a version-specific answer on a forum does not work, the version to check is the one in Gemfile.lock, and the one in the command history is the wrong place to look.

The same confusion has a second source, and it is nastier because it is invisible. config/boot.rb opens with ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__), so an inherited BUNDLE_GEMFILE wins over the application's own. With that variable pointing at an unrelated project, bin/rails -v in this application printed Rails 8.1.3.1 instead of Rails 8.1.4, from inside the application directory, with no warning of any kind. What that same inheritance does to rails new itself is the dead end documented in Rails for an MVP, counted, and it is worth reading before generating an application anywhere near another Ruby project.

Read a tutorial with bin/rails console --sandbox open

The position this page takes is that a Rails tutorial should not be read in a browser alone. Every claim in one is checkable in about four seconds, and the console has a mode built for exactly this use, which rolls the session back on exit:

$ bin/rails console --sandbox
blog[development] 001 > Post.create!(title: "x", body: "y"); puts Post.count
21
nil
blog[development] 002 > exit

$ bin/rails runner 'puts "after sandbox: #{Post.count}"'
after sandbox: 20

Twenty-one rows inside the session, twenty after it. Nothing to clean up, so there is no reason to be careful, and being careful is what stops people from trying the thing they just read.

The cost is real and worth saying out loud. Checking as you go roughly doubles how long a tutorial takes, and it does not work at all for the chapters that need a browser, a payment provider or a deployed host. It also builds a habit that reads as slow to somebody watching. What would change this position is tutorials carrying a tested-against version in the header, the way the Rails Guides are versioned with the framework; until a blog post does that, its Rails version is something you infer from its file paths.

The scaffold fixtures both say MyString

test/fixtures/posts.yml, written by bin/rails generate scaffold, gives both rows the same values:

one:
  title: MyString
  body: MyText

two:
  title: MyString
  body: MyText

A test asserting Post.find_by_title(@post.title).id == @post.id fails against those fixtures, and it failed here: Expected: 980190962, Actual: 298486374. The generated suite is a smoke test that every route responds, not evidence that any of the logic is right, and reading it as the latter is the most common misreading of a green first run.

What this page does not cover

Which learning resource to work through, and how hard the climb is from JavaScript, from another framework or from no programming at all, is the subject of Is Ruby on Rails hard to learn and is not answered here. What the generators put on disk, and what a first product still needs after they run, is counted in Rails for an MVP, counted. The difference between the language and the framework, for anybody still unsure whether those are one thing or two, is Ruby vs Ruby on Rails, and the SQLite default that rails new picks is argued in What database Rails uses.

No throughput number appears on this page. The laptop it was written on was running other jobs throughout, at a load average that read 3.38 4.22 4.01 across 12 cores when the last command finished, and a performance figure measured on a machine in that state is not a figure anybody can reproduce.

This list of moved idioms is also not exhaustive and cannot be. It is the set that showed up while generating one application, scaffolding one resource, generating one model and running the authentication generator, and every one of the checks in it is in the test file above rather than in anybody's memory.

#rails #ruby

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.