Getting Started with Rails
This guide covers getting up and running with Ruby on Rails.
After reading this guide, you will know:
- How to install Rails, create a new Rails application, and connect yourapplication to a database.
- The general layout of a Rails application.
- The basic principles of MVC (Model, View, Controller) and RESTful design.
- How to quickly generate the starting pieces of a Rails application.
- How to deploy your app to production using Kamal.
1. Introduction
Welcome to Ruby on Rails! In this guide, we'll walk through the core concepts ofbuilding web applications with Rails. You don't need any experience with Railsto follow along with this guide.
Rails is a web framework built for the Ruby programming language. Rails takesadvantage of many features of Ruby so westrongly recommend learning thebasics of Ruby so that you understand some of the basic terms and vocabulary youwill see in this tutorial.
2. Rails Philosophy
Rails is a web application development framework written in the Ruby programminglanguage. It is designed to make programming web applications easier by makingassumptions about what every developer needs to get started. It allows you towrite less code while accomplishing more than many other languages andframeworks. Experienced Rails developers also report that it makes webapplication development more fun.
Rails is opinionated software. It makes the assumption that there is a "best"way to do things, and it's designed to encourage that way - and in some cases todiscourage alternatives. If you learn "The Rails Way" you'll probably discover atremendous increase in productivity. If you persist in bringing old habits fromother languages to your Rails development, and trying to use patterns youlearned elsewhere, you may have a less happy experience.
The Rails philosophy includes two major guiding principles:
- Don't Repeat Yourself: DRY is a principle of software development whichstates that "Every piece of knowledge must have a single, unambiguous,authoritative representation within a system". By not writing the sameinformation over and over again, our code is more maintainable, moreextensible, and less buggy.
- Convention Over Configuration: Rails has opinions about the best way to domany things in a web application, and defaults to this set of conventions,rather than require that you define them yourself through endlessconfiguration files.
3. Creating a New Rails App
We're going to build a project calledstore
- a simple e-commerce app thatdemonstrates several of Rails' built-in features.
Any commands prefaced with a dollar sign$
should be run in the terminal.
3.1. Prerequisites
For this project, you will need:
- Ruby 3.2 or newer
- Rails 8.0.0 or newer
- A code editor
Follow theInstall Ruby on Rails Guide if you needto install Ruby and/or Rails.
Let's verify the correct version of Rails is installed. To display the currentversion, open a terminal and run the following. You should see a version numberprinted out:
$rails--versionRails 8.0.0
The version shown should be Rails 8.0.0 or higher.
3.2. Creating Your First Rails App
Rails comes with several commands to make life easier. Runrails --help
to seeall of the commands.
rails new
generates the foundation of a fresh Rails application for you, solet's start there.
To create ourstore
application, run the following command in your terminal:
$railsnew store
You can customize the application Rails generates by using flags. To seethese options, runrails new --help
.
After your new application is created, switch to its directory:
$cdstore
3.3. Directory Structure
Let's take a quick glance at the files and directories that are included in anew Rails application. You can open this folder in your code editor or runls -la
in your terminal to see the files and directories.
File/Folder | Purpose |
---|---|
app/ | Contains the controllers, models, views, helpers, mailers, jobs, and assets for your application.You'll focus mostly on this folder for the remainder of this guide. |
bin/ | Contains therails script that starts your app and can contain other scripts you use to set up, update, deploy, or run your application. |
config/ | Contains configuration for your application's routes, database, and more. This is covered in more detail inConfiguring Rails Applications. |
config.ru | Rack configuration for Rack-based servers used to start the application. |
db/ | Contains your current database schema, as well as the database migrations. |
Dockerfile | Configuration file for Docker. |
Gemfile Gemfile.lock | These files allow you to specify what gem dependencies are needed for your Rails application. These files are used by theBundler gem. |
lib/ | Extended modules for your application. |
log/ | Application log files. |
public/ | Contains static files and compiled assets. When your app is running, this directory will be exposed as-is. |
Rakefile | This file locates and loads tasks that can be run from the command line. The task definitions are defined throughout the components of Rails. Rather than changingRakefile , you should add your own tasks by adding files to thelib/tasks directory of your application. |
README.md | This is a brief instruction manual for your application. You should edit this file to tell others what your application does, how to set it up, and so on. |
script/ | Contains one-off or general purposescripts andbenchmarks. |
storage/ | Contains SQLite databases and Active Storage files for Disk Service. This is covered inActive Storage Overview. |
test/ | Unit tests, fixtures, and other test apparatus. These are covered inTesting Rails Applications. |
tmp/ | Temporary files (like cache and pid files). |
vendor/ | A place for all third-party code. In a typical Rails application this includes vendored gems. |
.dockerignore | This file tells Docker which files it should not copy into the container. |
.gitattributes | This file defines metadata for specific paths in a Git repository. This metadata can be used by Git and other tools to enhance their behavior. See thegitattributes documentation for more information. |
.git/ | Contains Git repository files. |
.github/ | Contains GitHub specific files. |
.gitignore | This file tells Git which files (or patterns) it should ignore. SeeGitHub - Ignoring files for more information about ignoring files. |
.kamal/ | Contains Kamal secrets and deployment hooks. |
.rubocop.yml | This file contains the configuration for RuboCop. |
.ruby-version | This file contains the default Ruby version. |
3.4. Model-View-Controller Basics
Rails code is organized using the Model-View-Controller (MVC) architecture. WithMVC, we have three main concepts where the majority of our code lives:
- Model - Manages the data in your application. Typically, your database tables.
- View - Handles rendering responses in different formats like HTML, JSON, XML,etc.
- Controller - Handles user interactions and the logic for each request.
Now that we've got a basic understanding of MVC, let's see how it's used inRails.
4. Hello, Rails!
Let's start easy and boot up our Rails server for the first time.
In your terminal, run the following command in thestore
directory:
$bin/railsserver
When we run commands inside an application directory, we should usebin/rails
. This makes sure the application's version of Rails is used.
This will start up a web server called Puma that will serve static files andyour Rails application:
=>Booting Puma=>Rails 8.0.0 application startingindevelopment=>Run`bin/railsserver--help`formore startup optionsPuma starting in single mode...* Puma version: 6.4.3 (ruby 3.3.5-p100) ("The Eagle of Durango")* Min threads: 3* Max threads: 3* Environment: development* PID: 12345* Listening on http://127.0.0.1:3000* Listening on http://[::1]:3000Use Ctrl-C to stop
To see your Rails application, openhttp://localhost:3000 in your browser. Youwill see the default Rails welcome page:
It works!
This page is thesmoke test for a new Rails application, ensuring thateverything is working behind the scenes to serve a page.
To stop the Rails server anytime, pressCtrl-C
in your terminal.
4.1. Autoloading in Development
Developer happiness is a cornerstone philosophy of Rails and one way ofachieving this is with automatic code reloading in development.
Once you start the Rails server, new files or changes to existing files aredetected and automatically loaded or reloaded as necessary. This allows you tofocus on building without having to restart your Rails server after everychange.
You may also notice that Rails applications rarely userequire
statements likeyou may have seen in other programming languages. Rails uses naming conventionsto require files automatically so you can focus on writing your applicationcode.
SeeAutoloading and Reloading Constantsfor more details.
5. Creating a Database Model
Active Record is a feature of Rails that maps relational databases to Ruby code.It helps generate the structured query language (SQL) for interacting with thedatabase like creating, updating, and deleting tables and records. Ourapplication is using SQLite which is the default for Rails.
Let's start by adding a database table to our Rails application to add productsto our simple e-commerce store.
$bin/railsgenerate model Product name:string
This command tells Rails to generate a model namedProduct
which has aname
column and type ofstring
in the database. Later on, you'll learn how to addother column types.
You'll see the following in your terminal:
invoke active_record create db/migrate/20240426151900_create_products.rb create app/models/product.rb invoke test_unit create test/models/product_test.rb create test/fixtures/products.yml
This command does several things. It creates...
- A migration in the
db/migrate
folder. - An Active Record model in
app/models/product.rb
. - Tests and test fixtures for this model.
Model names aresingular, because an instantiated model represents asingle record in the database (i.e., You are creating aproduct to add to thedatabase.).
5.1. Database Migrations
Amigration is a set of changes we want to make to our database.
By defining migrations, we're telling Rails how to change the database to add,change, or remove tables, columns or other attributes of our database. Thishelps keep track of changes we make in development (only on our computer) sothey can be deployed to production (live, online!) safely.
In your code editor, open the migration Rails created for us so we can see whatthe migration does. This is located indb/migrate/<timestamp>_create_products.rb
:
classCreateProducts<ActiveRecord::Migration[8.0]defchangecreate_table:productsdo|t|t.string:namet.timestampsendendend
This migration is telling Rails to create a new database table namedproducts
.
In contrast to the model above, Rails makes the database table namesplural, because the database holds all of the instances of each model (i.e.,You are creating a database ofproducts).
Thecreate_table
block then defines which columns and types should be definedin this database table.
t.string :name
tells Rails to create a column in theproducts
table calledname
and set the type asstring
.
t.timestamps
is a shortcut for defining two columns on your models:created_at:datetime
andupdated_at:datetime
. You'll see these columns onmost Active Record models in Rails and they are automatically set by ActiveRecord when creating or updating records.
5.2. Running Migrations
Now that you have defined what changes to make to the database, use thefollowing command to run the migrations:
$bin/railsdb:migrate
This command checks for any new migrations and applies them to your database.Its output looks like this:
== 20240426151900 CreateProducts: migrating ===================================-- create_table(:products) ->0.0030s== 20240426151900 CreateProducts: migrated (0.0031s) ==========================
If you make a mistake, you can runbin/rails db:rollback
to undo the lastmigration.
6. Rails Console
Now that we have created our products table, we can interact with it in Rails.Let's try it out.
For this, we're going to use a Rails feature called theconsole. The consoleis a helpful, interactive tool for testing our code in our Rails application.
$bin/railsconsole
You will be presented with a prompt like the following:
Loading development environment (Rails 8.0.0)store(dev)>
Here we can type code that will be executed when we hitEnter
. Let's tryprinting out the Rails version:
store(dev)>Rails.version=>"8.0.0"
It works!
7. Active Record Model Basics
When we ran the Rails model generator to create theProduct
model, it createda file atapp/models/product.rb
. This file creates a class that uses ActiveRecord for interacting with ourproducts
database table.
classProduct<ApplicationRecordend
You might be surprised that there is no code in this class. How does Rails knowwhat defines this model?
When theProduct
model is used, Rails will query the database table for thecolumn names and types and automatically generate code for these attributes.Rails saves us from writing this boilerplate code and instead takes care of itfor us behind the scenes so we can focus on our application logic instead.
Let's use the Rails console to see what columns Rails detects for the Productmodel.
Run:
store(dev)>Product.column_names
And you should see:
=>["id","name","created_at","updated_at"]
Rails asked the database for column information above and used that informationto define attributes on theProduct
class dynamically so you don't have tomanually define each of them. This is one example of how Rails makes developmenta breeze.
7.1. Creating Records
We can instantiate a new Product record with the following code:
store(dev)>product=Product.new(name:"T-Shirt")=>#<Product:0x000000012e616c30id:nil,name:"T-Shirt",created_at:nil,updated_at:nil>
Theproduct
variable is an instance ofProduct
. It has not been saved to thedatabase, and so does not have an ID, created_at, or updated_at timestamps.
We can callsave
to write the record to the database.
store(dev)>product.save TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Store'*/ Product Create (0.9ms) INSERT INTO "products" ("name", "created_at", "updated_at") VALUES ('T-Shirt', '2024-11-09 16:35:01.117836', '2024-11-09 16:35:01.117836') RETURNING "id" /*application='Store'*/ TRANSACTION (0.9ms) COMMIT TRANSACTION /*application='Store'*/=>true
Whensave
is called, Rails takes the attributes in memory and generates anINSERT
SQL query to insert this record into the database.
Rails also updates the object in memory with the database recordid
along withthecreated_at
andupdated_at
timestamps. We can see that by printing outtheproduct
variable.
store(dev)>product=> #<Product:0x00000001221f6260 id: 1, name: "T-Shirt", created_at: "2024-11-09 16:35:01.117836000 +0000", updated_at: "2024-11-09 16:35:01.117836000 +0000">
Similar tosave
, we can usecreate
to instantiate and save an Active Recordobject in a single call.
store(dev)>Product.create(name:"Pants") TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Store'*/ Product Create (0.4ms) INSERT INTO "products" ("name", "created_at", "updated_at") VALUES ('Pants', '2024-11-09 16:36:01.856751', '2024-11-09 16:36:01.856751') RETURNING "id" /*application='Store'*/ TRANSACTION (0.1ms) COMMIT TRANSACTION /*application='Store'*/=> #<Product:0x0000000120485c80 id: 2, name: "Pants", created_at: "2024-11-09 16:36:01.856751000 +0000", updated_at: "2024-11-09 16:36:01.856751000 +0000">
7.2. Querying Records
We can also look up records from the database using our Active Record model.
To find all the Product records in the database, we can use theall
method.This is aclass method, which is why we can use it on Product (versus aninstance method that we would call on the product instance, likesave
above).
store(dev)>Product.all Product Load (0.1ms) SELECT "products".* FROM "products" /* loading for pp */ LIMIT 11 /*application='Store'*/=> [#<Product:0x0000000121845158 id: 1, name: "T-Shirt", created_at: "2024-11-09 16:35:01.117836000 +0000", updated_at: "2024-11-09 16:35:01.117836000 +0000">, #<Product:0x0000000121845018 id: 2, name: "Pants", created_at: "2024-11-09 16:36:01.856751000 +0000", updated_at: "2024-11-09 16:36:01.856751000 +0000">]
This generates aSELECT
SQL query to load all records from theproducts
table. Each record is automatically converted into an instance of our ProductActive Record model so we can easily work with them from Ruby.
Theall
method returns anActiveRecord::Relation
object which is anArray-like collection of database records with features to filter, sort, andexecute other database operations.
7.3. Filtering & Ordering Records
What if we want to filter the results from our database? We can usewhere
tofilter records by a column.
store(dev)>Product.where(name:"Pants") Product Load (1.5ms) SELECT "products".* FROM "products" WHERE "products"."name" = 'Pants' /* loading for pp */ LIMIT 11 /*application='Store'*/=> [#<Product:0x000000012184d858 id: 2, name: "Pants", created_at: "2024-11-09 16:36:01.856751000 +0000", updated_at: "2024-11-09 16:36:01.856751000 +0000">]
This generates aSELECT
SQL query but also adds aWHERE
clause to filter therecords that have aname
matching"Pants"
. This also returns anActiveRecord::Relation
because multiple records may have the same name.
We can useorder(name: :asc)
to sort records by name in ascending alphabetical order.
store(dev)>Product.order(name: :asc) Product Load (0.3ms) SELECT "products".* FROM "products" /* loading for pp */ ORDER BY "products"."name" ASC LIMIT 11 /*application='Store'*/=> [#<Product:0x0000000120e02a88 id: 2, name: "Pants", created_at: "2024-11-09 16:36:01.856751000 +0000", updated_at: "2024-11-09 16:36:01.856751000 +0000">, #<Product:0x0000000120e02948 id: 1, name: "T-Shirt", created_at: "2024-11-09 16:35:01.117836000 +0000", updated_at: "2024-11-09 16:35:01.117836000 +0000">]
7.4. Finding Records
What if we want to find one specific record?
We can do this by using thefind
class method to look up a single record byID. Call the method and pass in the specific ID by using the following code:
store(dev)>Product.find(1) Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1 /*application='Store'*/=> #<Product:0x000000012054af08 id: 1, name: "T-Shirt", created_at: "2024-11-09 16:35:01.117836000 +0000", updated_at: "2024-11-09 16:35:01.117836000 +0000">
This generates aSELECT
query but specifies aWHERE
for theid
columnmatching the ID of1
that was passed in. It also adds aLIMIT
to only returna single record.
This time, we get aProduct
instance instead of anActiveRecord::Relation
since we're only retrieving a single record from the database.
7.5. Updating Records
Records can be updated in 2 ways: usingupdate
or assigning attributes andcallingsave
.
We can callupdate
on a Product instance and pass in a Hash of new attributesto save to the database. This will assign the attributes, run validations, andsave the changes to the database in one method call.
store(dev)>product=Product.find(1)store(dev)>product.update(name:"Shoes") TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Store'*/ Product Update (0.3ms) UPDATE "products" SET "name" = 'Shoes', "updated_at" = '2024-11-09 22:38:19.638912' WHERE "products"."id" = 1 /*application='Store'*/ TRANSACTION (0.4ms) COMMIT TRANSACTION /*application='Store'*/=>true
This updated the name of the "T-Shirt" product to "Shoes" in the database.Confirm this by runningProduct.all
again.
store(dev)>Product.all
You will see two products: Shoes and Pants.
Product Load (0.3ms) SELECT "products".* FROM "products" /* loading for pp */ LIMIT 11 /*application='Store'*/=>[#<Product:0x000000012c0f7300id:1,name:"Shoes",created_at:"2024-12-02 20:29:56.303546000 +0000", updated_at: "2024-12-02 20:30:14.127456000 +0000">, #<Product:0x000000012c0f71c0 id: 2, name: "Pants", created_at: "2024-12-02 20:30:02.997261000 +0000", updated_at: "2024-12-02 20:30:02.997261000 +0000">]
Alternatively, we can assign attributes and callsave
when we're ready tovalidate and save changes to the database.
Let's change the name "Shoes" back to "T-Shirt".
store(dev)>product=Product.find(1)store(dev)>product.name="T-Shirt"=>"T-Shirt"store(dev)>product.save TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Store'*/ Product Update (0.2ms) UPDATE "products" SET "name" = 'T-Shirt', "updated_at" = '2024-11-09 22:39:09.693548' WHERE "products"."id" = 1 /*application='Store'*/ TRANSACTION (0.0ms) COMMIT TRANSACTION /*application='Store'*/=>true
7.6. Deleting Records
Thedestroy
method can be used to delete a record from the database.
store(dev)>product.destroy TRANSACTION (0.1ms) BEGIN immediate TRANSACTION /*application='Store'*/ Product Destroy (0.4ms) DELETE FROM "products" WHERE "products"."id" = 1 /*application='Store'*/ TRANSACTION (0.1ms) COMMIT TRANSACTION /*application='Store'*/=> #<Product:0x0000000125813d48 id: 1, name: "T-Shirt", created_at: "2024-11-09 22:39:38.498730000 +0000", updated_at: "2024-11-09 22:39:38.498730000 +0000">
This deleted the T-Shirt product from our database. We can confirm this withProduct.all
to see that it only returns Pants.
store(dev)>Product.all Product Load (1.9ms) SELECT "products".* FROM "products" /* loading for pp */ LIMIT 11 /*application='Store'*/=>[#<Product:0x000000012abde4c8id:2,name:"Pants",created_at:"2024-11-09 22:33:19.638912000 +0000", updated_at: "2024-11-09 22:33:19.638912000 +0000">]
7.7. Validations
Active Record providesvalidations which allows you to ensure data insertedinto the database adheres to certain rules.
Let's add apresence
validation to the Product model to ensure that allproducts must have aname
.
classProduct<ApplicationRecordvalidates:name,presence:trueend
You might remember that Rails automatically reloads changes during development.However, if the console is running when you make updates to the code, you'llneed to manually refresh it. So let's do this now by running 'reload!'.
store(dev)>reload!Reloading...
Let's try to create a Product without a name in the Rails console.
store(dev)>product=Product.newstore(dev)>product.save=>false
This timesave
returnsfalse
because thename
attribute wasn't specified.
Rails automatically runs validations during create, update, and save operationsto ensure valid input. To see a list of errors generated by validations, we cancallerrors
on the instance.
store(dev)>product.errors=>#<ActiveModel::Errors[#<ActiveModel::Errorattribute=name,type=blank,options={}>]>
This returns anActiveModel::Errors
object that can tell us exactly whicherrors are present.
It also can generate friendly error messages for us that we can use in our userinterface.
store(dev)>product.errors.full_messages=>["Name can't be blank"]
Now let's build a web interface for our Products.
We are done with the console for now, so you can exit out of it by runningexit
.
8. A Request's Journey Through Rails
To get Rails saying "Hello", you need to create at minimum aroute, acontroller with anaction, and aview. A route maps a request to acontroller action. A controller action performs the necessary work to handle therequest, and prepares any data for the view. A view displays data in a desiredformat.
In terms of implementation: Routes are rules written in a RubyDSL (Domain-Specific Language).Controllers are Ruby classes, and their public methods are actions. And viewsare templates, usually written in a mixture of HTML and Ruby.
That's the short of it, but we’re going to walk through each of these steps inmore detail next.
9. Routes
In Rails, a route is the part of the URL that determines how an incoming HTTPrequest is directed to the appropriate controller and action for processing.First, let's do a quick refresher of URLs and HTTP Request methods.
9.1. Parts of a URL
Let's examine the different parts of a URL:
http://example.org/products?sale=true&sort=asc
In this URL, each part has a name:
https
is theprotocolexample.org
is thehost/products
is thepath?sale=true&sort=asc
are thequery parameters
9.2. HTTP Methods and Their Purpose
HTTP requests use methods to tell a server what action it should perform for agiven URL. Here are the most common methods:
- A
GET
request tells the server to retrieve the data for a given URL (e.g.,loading a page or fetching a record). - A
POST
request will submit data to the URL for processing (usually creatinga new record). - A
PUT
orPATCH
request submits data to a URL to update an existing record. - A
DELETE
request to a URL tells the server to delete a record.
9.3. Rails Routes
Aroute
in Rails refers to a line of code that pairs an HTTP Method and a URLpath. The route also tells Rails whichcontroller
andaction
should respondto a request.
To define a route in Rails, let's go back to your code editor and add thefollowing route toconfig/routes.rb
Rails.application.routes.drawdoget"/products",to:"products#index"end
This route tells Rails to look for GET requests to the/products
path. In thisexample, we specified"products#index"
for where to route the request.
When Rails sees a request that matches, it will send the request to theProductsController
and theindex
action inside of that controller. This ishow Rails will process the request and return a response to the browser.
You'll notice that we don't need to specify the protocol, domain, or queryparams in our routes. That's basically because the protocol and domain make surethe request reaches your server. From there, Rails picks up the request andknows which path to use for responding to the request based on what routes aredefined. The query params are like options that Rails can use to apply to therequest, so they are typically used in the controller for filtering the data.
Let's look at another example. Add this line after the previous route:
post"/products",to:"products#create"
Here, we've told Rails to take POST requests to "/products" and process themwith theProductsController
using thecreate
action.
Routes may also need to match URLs with certain patterns. So how does that work?
get"/products/:id",to:"products#show"
This route has:id
in it. This is called aparameter
and it captures aportion of the URL to be used later for processing the request.
If a user visits/products/1
, the:id
param is set to1
and can be used inthe controller action to look up and display the Product record with an ID of 1./products/2
would display Product with an ID of 2 and so on.
Route parameters don't have to be Integers, either.
For example, you could have a blog with articles and match/blog/hello-world
with the following route:
get"/blog/:title",to:"blog#show"
Rails will capturehello-world
out of/blog/hello-world
and this can be usedto look up the blog post with the matching title.
9.3.1. CRUD Routes
There are 4 common actions you will generally need for a resource: Create, Read,Update, Delete (CRUD). This translates to 8 typical routes:
- Index - Shows all the records
- New - Renders a form for creating a new record
- Create - Processes the new form submission, handling errors and creating therecord
- Show - Renders a specific record for viewing
- Edit - Renders a form for updating a specific record
- Update (full) - Handles the edit form submission, handling errors and updating the entire record, and typically triggered by a PUT request.
- Update (partial) - Handles the edit form submission, handling errors and updating specific attributes of the record, and typically triggered by a PATCH request.
- Destroy - Handles deleting a specific record
We can add routes for these CRUD actions with the following:
get"/products",to:"products#index"get"/products/new",to:"products#new"post"/products",to:"products#create"get"/products/:id",to:"products#show"get"/products/:id/edit",to:"products#edit"patch"/products/:id",to:"products#update"put"/products/:id",to:"products#update"delete"/products/:id",to:"products#destroy"
9.3.2. Resource Routes
Typing out these routes every time is redundant, so Rails provides a shortcutfor defining them. To create all of the same CRUD routes, replace the aboveroutes with this single line:
resources:products
If you don’t want all these CRUD actions, you specify exactly what youneed. Check out therouting guide for details.
9.4. Routes Command
Rails provides a command that displays all the routes your application respondsto.
In your terminal, run the following command.
$bin/railsroutes
You'll see this in the output which are the routes generated byresources :products
Prefix Verb URI Pattern Controller#Action products GET /products(.:format) products#index POST /products(.:format) products#create new_product GET /products/new(.:format) products#new edit_product GET /products/:id/edit(.:format) products#edit product GET /products/:id(.:format) products#show PATCH /products/:id(.:format) products#update PUT /products/:id(.:format) products#update DELETE /products/:id(.:format) products#destroy
You'll also see routes from other built-in Rails features like health checks.
10. Controllers & Actions
Now that we've defined routes for Products, let's implement the controller andactions to handle requests to these URLs.
This command will generate aProductsController
with an index action. Sincewe've already set up routes, we can skip that part of the generator using aflag.
$bin/railsgenerate controller Products index--skip-routes create app/controllers/products_controller.rb invoke erb create app/views/products create app/views/products/index.html.erb invoke test_unit create test/controllers/products_controller_test.rb invoke helper create app/helpers/products_helper.rb invoke test_unit
This command generates a handful of files for our controller:
- The controller itself
- A views folder for the controller we generated
- A view file for the action we specified when generating the controller
- A test file for this controller
- A helper file for extracting logic in our views
Let's take a look at the ProductsController defined inapp/controllers/products_controller.rb
. It looks like this:
classProductsController<ApplicationControllerdefindexendend
You may notice the file nameproducts_controller.rb
is an underscoredversion of the Class this file defines,ProductsController
. This pattern helpsRails to automatically load code without having to userequire
like you mayhave seen in other languages.
Theindex
method here is an Action. Even though it's an empty method, Railswill default to rendering a template with the matching name.
Theindex
action will renderapp/views/products/index.html.erb
. If we openup that file in our code editor, we'll see the HTML it renders.
<h1>Products#index</h1><p>Find me in app/views/products/index.html.erb</p>
10.1. Making Requests
Let's see this in our browser. First, runbin/rails server
in your terminal tostart the Rails server. Then openhttp://localhost:3000 and you will see theRails welcome page.
If we openhttp://localhost:3000/products in the browser, Rails will render theproducts index HTML.
Our browser requested/products
and Rails matched this route toproducts#index
. Rails sent the request to theProductsController
and calledtheindex
action. Since this action was empty, Rails rendered the matchingtemplate atapp/views/products/index.html.erb
and returned that to ourbrowser. Pretty cool!
If we openconfig/routes.rb
, we can tell Rails the root route should renderthe Products index action by adding this line:
root"products#index"
Now when you visithttp://localhost:3000, Rails will render Products#index.
10.2. Instance Variables
Let's take this a step further and render some records from our database.
In theindex
action, let's add a database query and assign it to an instancevariable. Rails uses instance variables (variables that start with an @) toshare data with the views.
classProductsController<ApplicationControllerdefindex@products=Product.allendend
Inapp/views/products/index.html.erb
, we can replace the HTML with this ERB:
<%=debug@products%>
ERB is short forEmbedded Rubyand allows us to execute Ruby code to dynamically generate HTML with Rails. The<%= %>
tag tells ERB to execute the Ruby code inside and output the returnvalue. In our case, this takes@products
, converts it to YAML, and outputs theYAML.
Now refreshhttp://localhost:3000/ in your browser and you'll see that theoutput has changed. What you're seeing is the records in your database beingdisplayed in YAML format.
Thedebug
helper prints out variables in YAML format to help with debugging.For example, if you weren't paying attention and typed singular@product
instead of plural@products
, the debug helper could help you identify that thevariable was not set correctly in the controller.
Check out theAction View Helpers guide to seemore helpers that are available.
Let's updateapp/views/products/index.html.erb
to render all of our productnames.
<h1>Products</h1><divid="products"><%@products.eachdo|product|%><div><%=product.name%></div><%end%></div>
Using ERB, this code loops through each product in the@products
ActiveRecord::Relation
object and renders a<div>
tag containing the productname.
We've used a new ERB tag this time as well.<% %>
evaluates the Ruby code butdoes not output the return value. That ignores the output of@products.each
which would output an array that we don't want in our HTML.
10.3. CRUD Actions
We need to be able to access individual products. This is the R in CRUD to reada resource.
We've already defined the route for individual products with ourresources :products
route. This generates/products/:id
as a route thatpoints toproducts#show
.
Now we need to add that action to theProductsController
and define whathappens when it is called.
10.4. Showing Individual Products
Open the Products controller and add theshow
action like this:
classProductsController<ApplicationControllerdefindex@products=Product.allenddefshow@product=Product.find(params[:id])endend
Theshow
action here defines thesingular@product
because it's loading asingle record from the database, in other words: Show this one product. We useplural@products
inindex
because we're loading multiple products.
To query the database, we useparams
to access the request parameters. In thiscase, we're using the:id
from our route/products/:id
. When we visit/products/1
, the params hash contains{id: 1}
which results in ourshow
action callingProduct.find(1)
to load Product with ID of1
from thedatabase.
We need a view for the show action next. Following the Rails naming conventions,theProductsController
expects views inapp/views
in a subfolder namedproducts
.
Theshow
action expects a file inapp/views/products/show.html.erb
. Let'screate that file in our editor and add the following contents:
<h1><%=@product.name%></h1><%=link_to"Back",products_path%>
It would be helpful for the index page to link to the show page for each productso we can click on them to navigate. We can update theapp/views/products/index.html.erb
view to link to this new page to use ananchor tag to the path for theshow
action.
<h1>Products</h1><divid="products"><%@products.eachdo|product|%><div><ahref="/products/<%=product.id%>"><%=product.name%></a></div><%end%></div>
Refresh this page in your browser and you'll see that this works, but we can dobetter.
Rails provides helper methods for generating paths and URLs. When you runbin/rails routes
, you'll see the Prefix column. This prefix matches thehelpers you can use for generating URLs with Ruby code.
Prefix Verb URI Pattern Controller#Action products GET /products(.:format) products#index product GET /products/:id(.:format) products#show
These route prefixes give us helpers like the following:
products_path
generates"/products"
products_url
generates"http://localhost:3000/products"
product_path(1)
generates"/products/1"
product_url(1)
generates"http://localhost:3000/products/1"
_path
returns a relative path which the browser understands is for the currentdomain.
_url
returns a full URL including the protocol, host, and port.
URL helpers are useful for rendering emails that will be viewed outside of thebrowser.
Combined with thelink_to
helper, we can generate anchor tags and use the URLhelper to do this cleanly in Ruby.link_to
accepts the display content for thelink (product.name
)and the path or URL to link to for thehref
attribute(product
).
Let's refactor this to use these helpers:
<h1>Products</h1><divid="products"><%@products.eachdo|product|%><div><%=link_toproduct.name,product%></div><%end%></div>
10.5. Creating Products
So far we've had to create products in the Rails console, but let's make thiswork in the browser.
We need to create two actions for create:
- The new product form to collect product information
- The create action in the controller to save the product and check for errors
Let's start with our controller actions.
classProductsController<ApplicationControllerdefindex@products=Product.allenddefshow@product=Product.find(params[:id])enddefnew@product=Product.newendend
Thenew
action instantiates a newProduct
which we will use for displayingthe form fields.
We can updateapp/views/products/index.html.erb
to link to the new action.
<h1>Products</h1><%=link_to"New product",new_product_path%><divid="products"><%@products.eachdo|product|%><div><%=link_toproduct.name,product%></div><%end%></div>
Let's createapp/views/products/new.html.erb
to render the form for this newProduct
.
<h1>New product</h1><%=form_withmodel:@productdo|form|%><div><%=form.label:name%><%=form.text_field:name%></div><div><%=form.submit%></div><%end%>
In this view, we are using the Railsform_with
helper to generate an HTML formto create products. This helper uses aform builder to handle things like CSRFtokens, generating the URL based upon themodel:
provided, and even tailoringthe submit button text to the model.
If you open this page in your browser and View Source, the HTML for the formwill look like this:
<formaction="/products"accept-charset="UTF-8"method="post"><inputtype="hidden"name="authenticity_token"value="UHQSKXCaFqy_aoK760zpSMUPy6TMnsLNgbPMABwN1zpW-Jx6k-2mISiF0ulZOINmfxPdg5xMyZqdxSW1UK-H-Q"autocomplete="off"><div><labelfor="product_name">Name</label><inputtype="text"name="product[name]"id="product_name"></div><div><inputtype="submit"name="commit"value="Create Product"data-disable-with="Create Product"></div></form>
The form builder has included a CSRF token for security, configured the form forUTF-8 support, set the input field names and even added a disabled state for thesubmit button.
Because we passed a newProduct
instance to the form builder, it automaticallygenerated a form configured to send aPOST
request to/products
, which isthe default route for creating a new record.
To handle this, we first need to implement thecreate
action in ourcontroller.
classProductsController<ApplicationControllerdefindex@products=Product.allenddefshow@product=Product.find(params[:id])enddefnew@product=Product.newenddefcreate@product=Product.new(product_params)if@product.saveredirect_to@productelserender:new,status: :unprocessable_entityendendprivatedefproduct_paramsparams.expect(product:[:name])endend
10.5.1. Strong Parameters
Thecreate
action handles the data submitted by the form, but it needs to befiltered for security. That's where theproduct_params
method comes into play.
Inproduct_params
, we tell Rails to inspect the params and ensure there is akey named:product
with an array of parameters as the value. The onlypermitted parameters for products is:name
and Rails will ignore any otherparameters. This protects our application from malicious users who might try tohack our application.
10.5.2. Handling Errors
After assigning these params to the newProduct
, we can try to save it to thedatabase.@product.save
tells Active Record to run validations and save therecord to the database.
Ifsave
is successful, we want to redirect to the new product. Whenredirect_to
is given an Active Record object, Rails generates a path for thatrecord's show action.
redirect_to@product
Since@product
is aProduct
instance, Rails pluralizes the model name andincludes the object's ID in the path to produce"/products/2"
for theredirect.
Whensave
is unsuccessful and the record wasn't valid, we want to re-renderthe form so the user can fix the invalid data. In theelse
clause, we tellRails torender :new
. Rails knows we're in theProducts
controller, so itshould renderapp/views/products/new.html.erb
. Since we've set the@product
variable increate
, we can render that template and the form will be populatedwith ourProduct
data even though it wasn't able to be saved in the database.
We also set the HTTP status to 422 Unprocessable Entity to tell the browser thisPOST request failed and to handle it accordingly.
10.6. Editing Products
The process of editing records is very similar to creating records. Instead ofnew
andcreate
actions, we will haveedit
andupdate
.
Let's implement them in the controller with the following:
classProductsController<ApplicationControllerdefindex@products=Product.allenddefshow@product=Product.find(params[:id])enddefnew@product=Product.newenddefcreate@product=Product.new(product_params)if@product.saveredirect_to@productelserender:new,status: :unprocessable_entityendenddefedit@product=Product.find(params[:id])enddefupdate@product=Product.find(params[:id])if@product.update(product_params)redirect_to@productelserender:edit,status: :unprocessable_entityendendprivatedefproduct_paramsparams.expect(product:[:name])endend
Next we can add an Edit link toapp/views/products/show.html.erb
:
<h1><%=@product.name%></h1><%=link_to"Back",products_path%><%=link_to"Edit",edit_product_path(@product)%>
10.6.1. Before Actions
Sinceedit
andupdate
require an existing database record likeshow
we candeduplicate this into abefore_action
.
Abefore_action
allows you to extract shared code between actions and run itbefore the action. In the above controller code,@product = Product.find(params[:id])
is defined in three different methods.Extracting this query to a before action calledset_product
cleans up our codefor each action.
This is a good example of the DRY (Don't Repeat Yourself) philosophy in action.
classProductsController<ApplicationControllerbefore_action:set_product,only:%i[ show edit update ]defindex@products=Product.allenddefshowenddefnew@product=Product.newenddefcreate@product=Product.new(product_params)if@product.saveredirect_to@productelserender:new,status: :unprocessable_entityendenddefeditenddefupdateif@product.update(product_params)redirect_to@productelserender:edit,status: :unprocessable_entityendendprivatedefset_product@product=Product.find(params[:id])enddefproduct_paramsparams.expect(product:[:name])endend
10.6.2. Extracting Partials
We've already written a form for creating new products. Wouldn't it be nice ifwe could reuse that for edit and update? We can, using a feature called"partials" that allows you to reuse a view in multiple places.
We can move the form into a file calledapp/views/products/_form.html.erb
. Thefilename starts with an underscore to denote this is a partial.
We also want to replace any instance variables with a local variable, which wecan define when we render the partial. We'll do this by replacing@product
withproduct
.
<%=form_withmodel:productdo|form|%><div><%=form.label:name%><%=form.text_field:name%></div><div><%=form.submit%></div><%end%>
Using local variables allows partials to be reused multiple times on thesame page with a different value each time. This comes in handy rendering listsof items like an index page.
To use this partial in ourapp/views/products/new.html.erb
view, we canreplace the form with a render call:
<h1>New product</h1><%=render"form",product:@product%><%=link_to"Cancel",products_path%>
The edit view becomes almost the exact same thing thanks to the form partial.Let's createapp/views/products/edit.html.erb
with the following:
<h1>Edit product</h1><%=render"form",product:@product%><%=link_to"Cancel",@product%>
To learn more about view partials, check out theAction View Guide.
10.7. Deleting Products
The last feature we need to implement is deleting products. We will add adestroy
action to ourProductsController
to handleDELETE /products/:id
requests.
Addingdestroy
tobefore_action :set_product
lets us set the@product
instance variable in the same way we do for the other actions.
classProductsController<ApplicationControllerbefore_action:set_product,only:%i[ show edit update destroy ]defindex@products=Product.allenddefshowenddefnew@product=Product.newenddefcreate@product=Product.new(product_params)if@product.saveredirect_to@productelserender:new,status: :unprocessable_entityendenddefeditenddefupdateif@product.update(product_params)redirect_to@productelserender:edit,status: :unprocessable_entityendenddefdestroy@product.destroyredirect_toproducts_pathendprivatedefset_product@product=Product.find(params[:id])enddefproduct_paramsparams.expect(product:[:name])endend
To make this work, we need to add a Delete button toapp/views/products/show.html.erb
:
<h1><%=@product.name%></h1><%=link_to"Back",products_path%><%=link_to"Edit",edit_product_path(@product)%><%=button_to"Delete",@product,method: :delete,data:{turbo_confirm:"Are you sure?"}%>
button_to
generates a form with a single button in it with the "Delete" text.When this button is clicked, it submits the form which makes aDELETE
requestto/products/:id
which triggers thedestroy
action in our controller.
Theturbo_confirm
data attribute tells the Turbo JavaScript library to ask theuser to confirm before submitting the form. We'll dig more into that shortly.
11. Adding Authentication
Anyone can edit or delete products which isn't safe. Let's add some security byrequiring a user to be authenticated to manage products.
Rails comes with an authentication generator that we can use. It creates Userand Session models and the controllers and views necessary to login to ourapplication.
Head back to your terminal and run the following command:
$bin/railsgenerate authentication
Then migrate the database to add the User and Session tables.
$bin/railsdb:migrate
Open the Rails console to create a User.
$bin/railsconsole
UseUser.create!
method to create a User in the Rails console. Feel free touse your own email and password instead of the example.
store(dev)>User.create!email_address:"you@example.org",password:"s3cr3t",password_confirmation:"s3cr3t"
Restart your Rails server so it picks up thebcrypt
gem added by thegenerator. BCrypt is used for securely hashing passwords for authentication.
$bin/railsserver
When you visit any page, Rails will prompt for a username and password. Enterthe email and password you used when creating the User record.
Try it out by visitinghttp://localhost:3000/products/new
If you enter the correct username and password, it will allow you through. Yourbrowser will also store these credentials for future requests so you don't haveto type it in every page view.
11.1. Adding Log Out
To log out of the application, we can add a button to the top ofapp/views/layouts/application.html.erb
. This layout is where you put HTML thatyou want to include in every page like a header or footer.
Add a small<nav>
section inside the<body>
with a link to Home and a Logout button and wrapyield
with a<main>
tag.
<!DOCTYPE html><html><!-- ... --><body><nav><%=link_to"Home",root_path%><%=button_to"Log out",session_path,method: :deleteifauthenticated?%></nav><main><%=yield%></main></body></html>
This will display a Log out button only if the user is authenticated. Whenclicked, it will send a DELETE request to the session path which will log theuser out.
11.2. Allowing Unauthenticated Access
However, our store's product index and show pages should be accessible toeveryone. By default, the Rails authentication generator will restrict all pagesto authenticated users only.
To allow guests to view products, we can allow unauthenticated access in ourcontroller.
classProductsController<ApplicationControllerallow_unauthenticated_accessonly:%i[ index show ]# ...end
Log out and visit the products index and show pages to see they're accessiblewithout being authenticated.
11.3. Showing Links for Authenticated Users Only
Since only logged in users can create products, we can modify theapp/views/products/index.html.erb
view to only display the new product link ifthe user is authenticated.
<%=link_to"New product",new_product_pathifauthenticated?%>
Click the Log out button and you'll see the New link is hidden. Log in athttp://localhost:3000/session/new and you'll see the New link on the index page.
Optionally, you can include a link to this route in the navbar to add a Loginlink if not authenticated.
<%=link_to"Login",new_session_pathunlessauthenticated?%>
You can also update the Edit and Delete links on theapp/views/products/show.html.erb
view to only display if authenticated.
<h1><%=@product.name%></h1><%=link_to"Back",products_path%><%ifauthenticated?%><%=link_to"Edit",edit_product_path(@product)%><%=button_to"Delete",@product,method: :delete,data:{turbo_confirm:"Are you sure?"}%><%end%>
12. Caching Products
Sometimes caching specific parts of a page can improve performance. Railssimplifies this process with Solid Cache, a database-backed cache store thatcomes included by default.
Using thecache
method, we can store HTML in the cache. Let's cache the headerinapp/views/products/show.html.erb
.
<%cache@productdo%><h1><%=@product.name%></h1><%end%>
By passing@product
intocache
, Rails generates a unique cache key for theproduct. Active Record objects have acache_key
method that returns a Stringlike"products/1"
. Thecache
helper in the views combines this with thetemplate digest to create a unique key for this HTML.
To enable caching in development, run the following command in your terminal.
$bin/railsdev:cache
When you visit a product's show action (like/products/2
), you'll see the newcaching lines in your Rails server logs:
Read fragment views/products/show:a5a585f985894cd27c8b3d49bb81de3a/products/1-20240918154439539125 (1.6ms)Write fragment views/products/show:a5a585f985894cd27c8b3d49bb81de3a/products/1-20240918154439539125 (4.0ms)
The first time we open this page, Rails will generate a cache key and ask thecache store if it exists. This is theRead fragment
line.
Since this is the first page view, the cache does not exist so the HTML isgenerated and written to the cache. We can see this as theWrite fragment
linein the logs.
Refresh the page and you'll see the logs no longer contain theWrite fragment
.
Read fragment views/products/show:a5a585f985894cd27c8b3d49bb81de3a/products/1-20240918154439539125 (1.3ms)
The cache entry was written by the last request, so Rails finds the cache entryon the second request. Rails also changes the cache key when records are updatedto ensure that it never renders stale cache data.
Learn more in theCaching with Rails guide.
13. Rich Text Fields with Action Text
Many applications need rich text with embeds (i.e. multimedia elements) andRails provides this functionality out of the box with Action Text.
To use Action Text, you'll first run the installer:
$bin/railsaction_text:install$bundle install$bin/railsdb:migrate
Restart your Rails server to make sure all the new features are loaded.
Now, let's add a rich text description field to our product.
First, add the following to theProduct
model:
classProduct<ApplicationRecordhas_rich_text:descriptionvalidates:name,presence:trueend
The form can now be updated to include a rich text field for editing thedescription inapp/views/products/_form.html.erb
before the submit button.
<%=form_withmodel:productdo|form|%><%# ... %><div><%=form.label:description,style:"display: block"%><%=form.rich_textarea:description%></div><div><%=form.submit%></div><%end%>
Our controller also needs to permit this new parameter when the form issubmitted, so we'll update the permitted params to include description inapp/controllers/products_controller.rb
# Only allow a list of trusted parameters through.defproduct_paramsparams.expect(product:[:name,:description])end
We also need to update the show view to display the description inapp/views/products/show.html.erb
:
<%cache@productdo%><h1><%=@product.name%></h1><%=@product.description%><%end%>
The cache key generated by Rails also changes when the view is modified. Thismakes sure the cache stays in sync with the latest version of the view template.
Create a new product and add a description with bold and italic text. You'll seethat the show page displays the formatted text and editing the product retainsthis rich text in the text area.
Check out theAction Text Overview to learn more.
14. File Uploads with Active Storage
Action Text is built upon another feature of Rails called Active Storage thatmakes it easy to upload files.
Try editing a product and dragging an image into the rich text editor, thenupdate the record. You'll see that Rails uploads this image and renders itinside the rich text editor. Cool, right?!
We can also use Active Storage directly. Let's add a featured image to theProduct
model.
classProduct<ApplicationRecordhas_one_attached:featured_imagehas_rich_text:descriptionvalidates:name,presence:trueend
Then we can add a file upload field to our product form before the submitbutton:
<%=form_withmodel:productdo|form|%><%# ... %><div><%=form.label:featured_image,style:"display: block"%><%=form.file_field:featured_image,accept:"image/*"%></div><div><%=form.submit%></div><%end%>
Add:featured_image
as a permitted parameter inapp/controllers/products_controller.rb
# Only allow a list of trusted parameters through.defproduct_paramsparams.expect(product:[:name,:description,:featured_image])end
Lastly, we want to display the featured image for our product inapp/views/products/show.html.erb
. Add the following to the top.
<%=image_tag@product.featured_imageif@product.featured_image.attached?%>
Try uploading an image for a product and you'll see the image displayed on theshow page after saving.
Check out theActive Storage Overview for moredetails.
15. Internationalization (I18n)
Rails makes it easy to translate your app into other languages.
Thetranslate
ort
helper in our views looks up a translation by name andreturns the text for the current locale.
Inapp/views/products/index.html.erb
, let's update the header tag to use atranslation.
<h1><%=t"hello"%></h1>
Refreshing the page, we seeHello world
is the header text now. Where did thatcome from?
Since the default language is in English, Rails looks inconfig/locales/en.yml
(which was created duringrails new
) for a matching key under the locale.
en:hello:"Helloworld"
Let's create a new locale file in our editor for Spanish and add a translationinconfig/locales/es.yml
.
es:hello:"Holamundo"
We need to tell Rails which locale to use. The simplest option is to look for alocale param in the URL. We can do this inapp/controllers/application_controller.rb
with the following:
classApplicationController<ActionController::Base# ...around_action:switch_localedefswitch_locale(&action)locale=params[:locale]||I18n.default_localeI18n.with_locale(locale,&action)endend
This will run every request and look forlocale
in the params or fallback tothe default locale. It sets the locale for the request and resets it after it'sfinished.
- Visithttp://localhost:3000/products?locale=en, you will see the Englishtranslation.
- Visithttp://localhost:3000/products?locale=es, you will see the Spanishtranslation.
- Visithttp://localhost:3000/products without a locale param, it will fallbackto English.
Let's update the index header to use a real translation instead of"Hello world"
.
<h1><%=t".title"%></h1>
Notice the.
beforetitle
? This tells Rails to use a relative localelookup. Relative lookups include the controller and action automatically in thekey so you don't have to type them every time. For.title
with the Englishlocale, it will look upen.products.index.title
.
Inconfig/locales/en.yml
we want to add thetitle
key underproducts
andindex
to match our controller, view, and translation name.
en:hello:"Helloworld"products:index:title:"Products"
In the Spanish locales file, we can do the same thing:
es:hello:"Holamundo"products:index:title:"Productos"
You'll now see "Products" when viewing the English locale and "Productos" whenviewing the Spanish locale.
Learn more about theRails Internationalization (I18n) API.
16. Adding In Stock Notifications
A common feature of e-commerce stores is an email subscription to get notifiedwhen a product is back in stock. Now that we've seen the basics of Rails, let'sadd this feature to our store.
16.1. Basic Inventory Tracking
First, let's add an inventory count to the Product model so we can keep track ofstock. We can generate this migration using the following command:
$bin/railsgenerate migration AddInventoryCountToProducts inventory_count:integer
Then let's run the migration.
$bin/railsdb:migrate
We'll need to add the inventory count to the product form inapp/views/products/_form.html.erb
.
<%=form_withmodel:productdo|form|%><%# ... %><div><%=form.label:inventory_count,style:"display: block"%><%=form.number_field:inventory_count%></div><div><%=form.submit%></div><%end%>
The controller also needs:inventory_count
added to the permitted parameters.
defproduct_paramsparams.expect(product:[:name,:description,:featured_image,:inventory_count])end
It would also be helpful to validate that our inventory count is never anegative number, so let's also add a validation for that in our model.
classProduct<ApplicationRecordhas_one_attached:featured_imagehas_rich_text:descriptionvalidates:name,presence:truevalidates:inventory_count,numericality:{greater_than_or_equal_to:0}end
With these changes, we can now update the inventory count of products in ourstore.
16.2. Adding Subscribers to Products
In order to notify users that a product is back in stock, we need to keep trackof these subscribers.
Let's generate a model called Subscriber to store these email addresses andassociate them with the respective product.
$bin/railsgenerate model Subscriber product:belongs_to email
Then run the new migration:
$bin/railsdb:migrate
By includingproduct:belongs_to
above, we told Rails that subscribers andproducts have a one-to-many relationship, meaning a Subscriber "belongs to" asingle Product instance.
A Product, however, can have many subscribers, so we then addhas_many :subscribers, dependent: :destroy
to our Product model to add thesecond part of this association between the two models. This tells Rails how tojoin queries between the two database tables.
classProduct<ApplicationRecordhas_many:subscribers,dependent: :destroyhas_one_attached:featured_imagehas_rich_text:descriptionvalidates:name,presence:truevalidates:inventory_count,numericality:{greater_than_or_equal_to:0}end
Now we need a controller to create these subscribers. Let's create that inapp/controllers/subscribers_controller.rb
with the following code:
classSubscribersController<ApplicationControllerallow_unauthenticated_accessbefore_action:set_productdefcreate@product.subscribers.where(subscriber_params).first_or_createredirect_to@product,notice:"You are now subscribed."endprivatedefset_product@product=Product.find(params[:product_id])enddefsubscriber_paramsparams.expect(subscriber:[:email])endend
Our redirect sets a notice in the Rails flash. The flash is used for storingmessages to display on the next page.
To display the flash message, let's add the notice toapp/views/layouts/application.html.erb
inside the body:
<html><!-- ... --><body><divclass="notice"><%=notice%></div><!-- ... --></body></html>
To subscribe users to a specific product, we'll use a nested route so we knowwhich product the subscriber belongs to. Inconfig/routes.rb
changeresources :products
to the following:
resources:productsdoresources:subscribers,only:[:create]end
On the product show page, we can check if there is inventory and display theamount in stock. Otherwise, we can display an out of stock message with thesubscribe form to get notified when it is back in stock.
Create a new partial atapp/views/products/_inventory.html.erb
and add thefollowing:
<%ifproduct.inventory_count?%><p><%=product.inventory_count%> in stock</p><%else%><p>Out of stock</p><p>Email me when available.</p><%=form_withmodel:[product,Subscriber.new]do|form|%><%=form.email_field:email,placeholder:"you@example.com",required:true%><%=form.submit"Submit"%><%end%><%end%>
Then updateapp/views/products/show.html.erb
to render this partial after thecache
block.
<%=render"inventory",product:@product%>
16.3. In Stock Email Notifications
Action Mailer is a feature of Rails that allows you to send emails. We'll use itto notify subscribers when a product is back in stock.
We can generate a mailer with the following command:
$bin/railsg mailer Product in_stock
This generates a class atapp/mailers/product_mailer.rb
with anin_stock
method.
Update this method to mail to a subscriber's email address.
classProductMailer<ApplicationMailer# Subject can be set in your I18n file at config/locales/en.yml# with the following lookup:## en.product_mailer.in_stock.subject#defin_stock@product=params[:product]mailto:params[:subscriber].emailendend
The mailer generator also generates two email templates in our views folder: onefor HTML and one for Text. We can update those to include a message and link tothe product.
Changeapp/views/product_mailer/in_stock.html.erb
to:
<h1>Good news!</h1><p><%=link_to@product.name,product_url(@product)%> is back in stock.</p>
Andapp/views/product_mailer/in_stock.text.erb
to:
Good news!<%=@product.name%> is back in stock.<%=product_url(@product)%>
We useproduct_url
instead ofproduct_path
in mailers because email clientsneed to know the full URL to open in the browser when the link is clicked.
We can test an email by opening the Rails console and loading a product andsubscriber to send to:
store(dev)>product=Product.firststore(dev)>subscriber=product.subscribers.find_or_create_by(email:"subscriber@example.org")store(dev)>ProductMailer.with(product:product,subscriber:subscriber).in_stock.deliver_later
You'll see that it prints out an email in the logs.
ProductMailer#in_stock: processed outbound mail in 63.0msDelivered mail 66a3a9afd5d4a_108b04a4c41443@local.mail (33.1ms)Date: Fri, 26 Jul 2024 08:50:39 -0500From: from@example.comTo: subscriber@example.comMessage-ID: <66a3a9afd5d4a_108b04a4c41443@local.mail>Subject: In stockMime-Version: 1.0Content-Type: multipart/alternative; boundary="--==_mimepart_66a3a9afd235e_108b04a4c4136f"; charset=UTF-8Content-Transfer-Encoding: 7bit----==_mimepart_66a3a9afd235e_108b04a4c4136fContent-Type: text/plain; charset=UTF-8Content-Transfer-Encoding: 7bitGood news!T-Shirt is back in stock.http://localhost:3000/products/1----==_mimepart_66a3a9afd235e_108b04a4c4136fContent-Type: text/html; charset=UTF-8Content-Transfer-Encoding: 7bit<!-- BEGIN app/views/layouts/mailer.html.erb --><!DOCTYPE html><html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <style> /* Email styles need to be inline */ </style> </head> <body> <!-- BEGIN app/views/product_mailer/in_stock.html.erb --><h1>Good news!</h1><p><a href="http://localhost:3000/products/1">T-Shirt</a> is back in stock.</p><!-- END app/views/product_mailer/in_stock.html.erb --> </body></html><!-- END app/views/layouts/mailer.html.erb -->----==_mimepart_66a3a9afd235e_108b04a4c4136f--Performed ActionMailer::MailDeliveryJob (Job ID: 5e2bd5f2-f54f-4088-ace3-3f6eb15aaf46) from Async(default) in 111.34ms
To trigger these emails, we can use a callback in the Product model to sendemails anytime the inventory count changes from 0 to a positive number.
classProduct<ApplicationRecordhas_many:subscribers,dependent: :destroyhas_one_attached:featured_imagehas_rich_text:descriptionvalidates:name,presence:truevalidates:inventory_count,numericality:{greater_than_or_equal_to:0}after_update_commit:notify_subscribers,if: :back_in_stock?defback_in_stock?inventory_count_previously_was.zero?&&inventory_count>0enddefnotify_subscriberssubscribers.eachdo|subscriber|ProductMailer.with(product:self,subscriber:subscriber).in_stock.deliver_laterendendend
after_update_commit
is an Active Record callback that is fired after changesare saved to the database.if: :back_in_stock?
tells the callback to run onlyif theback_in_stock?
method returns true.
Active Record keeps track of changes to attributes soback_in_stock?
checksthe previous value ofinventory_count
usinginventory_count_previously_was
.Then we can compare that against the current inventory count to determine if theproduct is back in stock.
notify_subscribers
uses the Active Record association to query thesubscribers
table for all subscribers for this specific product and thenqueues up thein_stock
email to be sent to each of them.
16.4. Extracting a Concern
The Product model now has a decent amount of code for handling notifications. Tobetter organize our code, we can extract this to anActiveSupport::Concern
. AConcern is a Ruby module with some syntactic sugar to make using them easier.
First let’s create the Notifications module.
Create a file atapp/models/product/notifications.rb
with the following:
moduleProduct::NotificationsextendActiveSupport::Concernincludeddohas_many:subscribers,dependent: :destroyafter_update_commit:notify_subscribers,if: :back_in_stock?enddefback_in_stock?inventory_count_previously_was.zero?&&inventory_count>0enddefnotify_subscriberssubscribers.eachdo|subscriber|ProductMailer.with(product:self,subscriber:subscriber).in_stock.deliver_laterendendend
When you include a module in a class, any code inside theincluded
block runsas if it’s part of that class. At the same time, the methods defined in themodule become regular methods you can call on objects (instances) of that class.
Now that the code triggering the notification has been extracted into theNotifications module, the Product model can be simplified to include theNotifications module.
classProduct<ApplicationRecordincludeNotificationshas_one_attached:featured_imagehas_rich_text:descriptionvalidates:name,presence:truevalidates:inventory_count,numericality:{greater_than_or_equal_to:0}end
Concerns are a great way to organize features of your Rails application. As youadd more features to the Product, the class will become messy. Instead, we canuse Concerns to extract each feature out into a self-contained module likeProduct::Notifications
which contains all the functionality for handlingsubscribers and how notifications are sent.
Extracting code into concerns also helps make features reusable. For example, wecould introduce a new model that also needs subscriber notifications. Thismodule could be used in multiple models to provide the same functionality.
16.5. Unsubscribe links
A subscriber may want to unsubscribe at some point, so let's build that next.
First, we need a route for unsubscribing that will be the URL we include inemails.
Rails.application.routes.drawdo# ...resources:productsdoresources:subscribers,only:[:create]endresource:unsubscribe,only:[:show]
The unsubscribe route is added at the top level and uses the singularresource
in order to handle routes like/unsubscribe?token=xyz
.
Active Record has a feature calledgenerates_token_for
that can generateunique tokens to find database records for different purposes. We can use thisfor generating a unique unsubscribe token to use in the email's unsubscribe URL.
classSubscriber<ApplicationRecordbelongs_to:productgenerates_token_for:unsubscribeend
Our controller will first look up the Subscriber record from the token in theURL. Once the subscriber is found, it will destroy the record and redirect tothe homepage. Createapp/controllers/unsubscribes_controller.rb
and add thefollowing code:
classUnsubscribesController<ApplicationControllerallow_unauthenticated_accessbefore_action:set_subscriberdefshow@subscriber&.destroyredirect_toroot_path,notice:"Unsubscribed successfully."endprivatedefset_subscriber@subscriber=Subscriber.find_by_token_for(:unsubscribe,params[:token])endend
Last but not least, let's add the unsubscribe link to our email templates.
Inapp/views/product_mailer/in_stock.html.erb
, add alink_to
:
<h1>Good news!</h1><p><%=link_to@product.name,product_url(@product)%> is back in stock.</p><%=link_to"Unsubscribe",unsubscribe_url(token:params[:subscriber].generate_token_for(:unsubscribe))%>
Inapp/views/product_mailer/in_stock.text.erb
, add the URL in plain text:
Good news!<%=@product.name%> is back in stock.<%=product_url(@product)%>Unsubscribe:<%=unsubscribe_url(token:params[:subscriber].generate_token_for(:unsubscribe))%>
When the unsubscribe link is clicked, the subscriber record will be deleted fromthe database. The controller also safely handles invalid or expired tokenswithout raising any errors.
Use the Rails console to send another email and test the unsubscribe link in thelogs.
17. Adding CSS & JavaScript
CSS & JavaScript are core to building web applications, so let's learn how touse them with Rails.
17.1. Propshaft
Rails' asset pipeline is called Propshaft. It takes your CSS, JavaScript,images, and other assets and serves them to your browser. In production,Propshaft keeps track of each version of your assets so they can be cached tomake your pages faster. Check out theAsset Pipeline guide to learn more about how this works.
Let's modifyapp/assets/stylesheets/application.css
and change our font tosans-serif.
body{font-family:Arial,Helvetica,sans-serif;padding:1rem;}nav{justify-content:flex-end;display:flex;font-size:0.875em;gap:0.5rem;max-width:1024px;margin:0auto;padding:1rem;}nava{display:inline-block;}main{max-width:1024px;margin:0auto;}.notice{color:green;}section.product{display:flex;gap:1rem;flex-direction:row;}section.productimg{border-radius:8px;flex-basis:50%;max-width:50%;}
Then we'll updateapp/views/products/show.html.erb
to use these new styles.
<p><%=link_to"Back",products_path%></p><sectionclass="product"><%=image_tag@product.featured_imageif@product.featured_image.attached?%><sectionclass="product-info"><%cache@productdo%><h1><%=@product.name%></h1><%=@product.description%><%end%><%=render"inventory",product:@product%><%ifauthenticated?%><%=link_to"Edit",edit_product_path(@product)%><%=button_to"Delete",@product,method: :delete,data:{turbo_confirm:"Are you sure?"}%><%end%></section></section>
Refresh your page and you'll see the CSS has been applied.
17.2. Import Maps
Rails uses import maps for JavaScript by default. This allows you to writemodern JavaScript modules with no build steps.
You can find the JavaScript pins inconfig/importmap.rb
. This file maps theJavaScript package names with the source file which is used to generate theimportmap tag in the browser.
# Pin npm packages by running ./bin/importmappin"application"pin"@hotwired/turbo-rails",to:"turbo.min.js"pin"@hotwired/stimulus",to:"stimulus.min.js"pin"@hotwired/stimulus-loading",to:"stimulus-loading.js"pin_all_from"app/javascript/controllers",under:"controllers"pin"trix"pin"@rails/actiontext",to:"actiontext.esm.js"
Each pin maps a JavaScript package name (e.g.,"@hotwired/turbo-rails"
)to a specific file or URL (e.g.,"turbo.min.js"
).pin_all_from
maps allfiles in a directory (e.g.,app/javascript/controllers
) to a namespace (e.g.,"controllers"
).
Import maps keep the setup clean and minimal, while still supporting modernJavaScript features.
What are these JavaScript files already in our import map? They are a frontendframework called Hotwire that Rails uses by default.
17.3. Hotwire
Hotwire is a JavaScript framework designed to take full advantage of server-sidegenerated HTML. It is comprised of 3 core components:
- Turbo handles navigation, formsubmissions, page components, and updates without writing any customJavaScript.
- Stimulus provides a framework for whenyou need custom JavaScript to add functionality to the page.
- Native allows you to make hybrid mobileapps by embedding your web app and progressively enhancing it with nativemobile features.
We haven't written any JavaScript yet, but we have been using Hotwire on thefrontend. For instance, the form you created to add and edit a product waspowered by Turbo.
Learn more in theAsset Pipeline andWorking with JavaScript in Railsguides.
18. Testing
Rails comes with a robust test suite. Let's write a test to ensure that thecorrect number of emails are sent when a product is back in stock.
18.1. Fixtures
When you generate a model using Rails, it automatically creates a correspondingfixture file in thetest/fixtures
directory.
Fixtures are predefined sets of data that populate your test database beforerunning tests. They allow you to define records with easy-to-remember names,making it simple to access them in your tests.
This file will be empty by default - you need to populate it with fixtures foryour tests.
Let’s update the product fixtures file attest/fixtures/products.yml
with thefollowing:
tshirt:name:T-Shirtinventory_count:15
And for subscribers, let's add these two fixtures totest/fixtures/subscribers.yml
:
david:product:tshirtemail:david@example.orgchris:product:tshirtemail:chris@example.org
You'll notice that we can reference theProduct
fixture by name here. Railsassociates this automatically for us in the database so we don't have to managerecord IDs and associations in tests.
These fixtures will be automatically inserted into the database when we run ourtest suite.
18.2. Testing Emails
Intest/models/product_test.rb
, let's add a test:
require"test_helper"classProductTest<ActiveSupport::TestCaseincludeActionMailer::TestHelpertest"sends email notifications when back in stock"doproduct=products(:tshirt)# Set product out of stockproduct.update(inventory_count:0)assert_emails2doproduct.update(inventory_count:99)endendend
Let's break down what this test is doing.
First, we include the Action Mailer test helpers so we can monitor emails sentduring the test.
Thetshirt
fixture is loaded using theproducts()
fixture helper and returnsthe Active Record object for that record. Each fixture generates a helper in thetest suite to make it easy to reference fixtures by name since their databaseIDs may be different each run.
Then we ensure the tshirt is out of stock by updating it's inventory to 0.
Next, we useassert_emails
to ensure 2 emails were generated by the codeinside the block. To trigger the emails, we update the product's inventory countinside the block. This triggers thenotify_subscribers
callback in the Productmodel to send emails. Once that's done executing,assert_emails
counts theemails and ensures it matches the expected count.
We can run the test suite withbin/rails test
or an individual test file bypassing the filename.
$bin/rails test test/models/product_test.rbRunning 1 tests in a single process (parallelization threshold is 50)Run options: --seed 3556# Running:.Finished in 0.343842s, 2.9083 runs/s, 5.8166 assertions/s.1 runs, 2 assertions, 0 failures, 0 errors, 0 skips
Our test passes!
Rails also generated an example test forProductMailer
attest/mailers/product_mailer_test.rb
. Let's update it to make it also pass.
require"test_helper"classProductMailerTest<ActionMailer::TestCasetest"in_stock"domail=ProductMailer.with(product:products(:tshirt),subscriber:subscribers(:david)).in_stockassert_equal"In stock",mail.subjectassert_equal["david@example.org"],mail.toassert_equal["from@example.com"],mail.fromassert_match"Good news!",mail.body.encodedendend
Let's run the entire test suite now and ensure all the tests pass.
$bin/rails testRunning 2 tests in a single process (parallelization threshold is 50)Run options: --seed 16302# Running:..Finished in 0.665856s, 3.0037 runs/s, 10.5128 assertions/s.2 runs, 7 assertions, 0 failures, 0 errors, 0 skips
You can use this as a starting place to continue building out a test suite withfull coverage of the application features.
Learn more aboutTesting Rails Applications
19. Consistently Formatted Code with RuboCop
When writing code we may sometimes use inconsistent formatting. Rails comes witha linter called RuboCop that helps keep our code formatted consistently.
We can check our code for consistency by running:
$bin/rubocop
This will print out any offenses and tell you what they are.
Inspecting 53 files.....................................................53 files inspected, no offenses detected
RuboCop can automatically fix offenses using the--autocorrect
flag (or its short version-a
).
$ bin/rubocop -a
20. Security
Rails includes the Brakeman gem for checking security issues with yourapplication - vulnerabilities that can lead to attacks such as sessionhijacking, session fixation, or redirection.
Runbin/brakeman
and it will analyze your application and output a report.
$bin/brakemanLoading scanner......== Overview ==Controllers: 6Models: 6Templates: 15Errors: 0Security Warnings: 0== Warning Types ==No warnings found
Learn more aboutSecuring Rails Applications
21. Continuous Integration with GitHub Actions
Rails apps generate a.github
folder that includes a prewritten GitHub Actionsconfiguration that runs rubocop, brakeman, and our test suite.
When we push our code to a GitHub repository with GitHub Actions enabled, itwill automatically run these steps and report back success or failure for each.This allows us to monitor our code changes for defects and issues and ensureconsistent quality for our work.
22. Deploying to Production
And now the fun part: let’s deploy your app.
Rails comes with a deployment tool calledKamal thatwe can use to deploy our application directly to a server. Kamal uses Dockercontainers to run your application and deploy with zero downtime.
By default, Rails comes with a production-ready Dockerfile that Kamal will useto build the Docker image, creating a containerized version of your applicationwith all its dependencies and configurations. This Dockerfile usesThruster to compress and serve assetsefficiently in production.
To deploy with Kamal, we need:
- A server running Ubuntu LTS with 1GB RAM or more. The server should run theUbuntu operating system with a Long-Term Support (LTS) version so it receivesregular security and bug fixes. Hetzner, DigitalOcean, and other hostingservices provide servers to get started.
- ADocker Hub account and access token. Docker Hubstores the image of the application so it can be downloaded and run on theserver.
On Docker Hub,create a Repositoryfor your application image. Use "store" as the name for the repository.
Openconfig/deploy.yml
and replace192.168.0.1
with your server's IP addressandyour-user
with your Docker Hub username.
# Name of your application. Used to uniquely configure containers.service:store# Name of the container image.image:your-user/store# Deploy to these servers.servers:web:-192.168.0.1# Credentials for your image host.registry:# Specify the registry server, if you're not using Docker Hub# server: registry.digitalocean.com / ghcr.io / ...username:your-user
Under theproxy:
section, you can add a domain to enable SSL for yourapplication too. Make sure your DNS record points to the server and Kamal willuse LetsEncrypt to issue an SSL certificate for the domain.
proxy:ssl:truehost:app.example.com
Create an access tokenwith Read & Write permissions on Docker's website so Kamal can push the Dockerimage for your application.
Then export the access token in the terminal so Kamal can find it.
export KAMAL_REGISTRY_PASSWORD=your-access-token
Run the following command to set up your server and deploy your application forthe first time.
$bin/kamal setup
Congratulations! Your new Rails application is live and in production!
To view your new Rails app in action, open your browser and enter your server'sIP address. You should see your store up and running.
After this, when you make changes to your app and want to push them toproduction, you can run the following:
$bin/kamal deploy
22.1. Adding a User to Production
To create and edit products in production, we need a User record in theproduction database.
You can use Kamal to open a production Rails console.
$bin/kamal console
store(prod)>User.create!(email_address:"you@example.org",password:"s3cr3t",password_confirmation:"s3cr3t")
Now you can log in to production with this email and password and manageproducts.
22.2. Background Jobs using Solid Queue
Background jobs allow you to run tasks asynchronously behind-the-scenes in aseparate process, preventing them from interrupting the user experience. Imaginesending in stock emails to 10,000 recipients. It could take a while, so we canoffload that task to a background job to keep the Rails app responsive.
In development, Rails uses the:async
queue adapter to process background jobswith ActiveJob. Async stores pending jobs in memory but it will lose pendingjobs on restart. This is great for development, but not production.
To make background jobs more robust, Rails usessolid_queue
for productionenvironments. Solid Queue stores jobs in the database and executes them in aseparate process.
Solid Queue is enabled for our production Kamal deployment using theSOLID_QUEUE_IN_PUMA: true
environment variable toconfig/deploy.yml
. Thistells our web server, Puma, to start and stop the Solid Queue processautomatically.
When emails are sent with Action Mailer'sdeliver_later
, these emails will besent to Active Job for sending in the background so they don't delay the HTTPrequest. With Solid Queue in production, emails will be sent in the background,automatically retried if they fail to send, and jobs are kept safe in thedatabase during restarts.
23. What's Next?
Congratulations on building and deploying your first Rails application!
We recommend continuing to add features and deploy updates to continue learning.Here are some ideas:
- Improve the design with CSS
- Add product reviews
- Finish translating the app into another language
- Add a checkout flow for payments
- Add wishlists for users to save products
- Add a carousel for product images
We also recommend learning more by reading other Ruby on Rails Guides:
- Active Record Basics
- Layouts and Rendering in Rails
- Testing Rails Applications
- Debugging Rails Applications
- Securing Rails Applications
Happy building!