Building a CRUD from Scratch

Learn how to build a complete CRUD (Create, Read, Update, Delete) application step by step. We'll create a simple "Posts" app with two fields: title and content.

article

What We'll Build

A posts application with:

List all posts View a post Create new post Edit post Delete post

1 Generate Migration

First, generate a migration file to create the posts collection:

$ beans g migration add-posts-collection

This creates a migration file in the migrations/ folder. Edit it to create the collection:

description migrations/XXXXXX_add-posts-collection.lua
local migration = {}

function migration.up()
  Adb.primary:CreateCollection("posts")
end

function migration.down()
  Adb.primary:DeleteCollection("posts")
end

return migration
info
The down() function defines how to rollback the migration.

2 Create the Model

Create the Post model to handle data validation and database operations.

description app/models/post.lua
local model = require("arango_model")

local Post = setmetatable({}, { __index = model })
Post.__index = Post
Post.COLLECTION = "posts"

function Post.new(data)
  local self = setmetatable(model.new(data), Post)

  self.validations = {
    title = { presence = { message = "must be present" } },
    content = { presence = { message = "must be present" } }
  }

  return self
end

return Post

check_circle Validation Rules

title - Required field with custom error message
content - Required field with custom error message

3 Define Routes

Add the resource routes to your routes file. One line creates all CRUD routes!

description config/routes.lua
-- Posts
Resource("posts")

route Generated Routes

Method Path Action Purpose
GET/postsindexList all posts
GET/posts/newnewShow create form
POST/postscreateCreate post
GET/posts/:idshowView post
GET/posts/:id/editeditShow edit form
PUT/posts/:idupdateUpdate post
DELETE/posts/:iddeleteDelete post

4 Create the Controller

Create the controller with all CRUD actions.

description app/controllers/posts_controller.lua
local Post = require("post")

local app = {}

-- GET /posts - List all posts
function app.index()
  local posts = Post.new():all()
  Page("posts/index", "app", { posts = posts })
end

-- GET /posts/new - Show create form
function app.new()
  Page("posts/new", "app", { post = {}, errors = {} })
end

-- POST /posts - Create a new post
function app.create()
  local post = Post.new():create({
    title = Params.title,
    content = Params.content
  })

  if #post.errors > 0 then
    Page("posts/new", "app", { post = post.data, errors = post.errors })
  else
    RedirectTo("/posts/" .. post.data._key)
    SetFlash("success", "Post created successfully")
  end
end

-- GET /posts/:id - Show a post
function app.show()
  local post = Post.new():find(Params.id)
  if post then
    Page("posts/show", "app", { post = post.data })
  else
    SetStatus(404)
    Write("Post not found")
  end
end

-- GET /posts/:id/edit - Show edit form
function app.edit()
  local post = Post.new():find(Params.id)
  if post then
    Page("posts/edit", "app", { post = post.data, errors = {} })
  else
    SetStatus(404)
    Write("Post not found")
  end
end

-- PUT /posts/:id - Update a post
function app.update()
  local post = Post.new():find(Params.id)
  post:update({
    title = Params.title,
    content = Params.content
  })

  if #post.errors > 0 then
    Page("posts/edit", "app", { post = post.data, errors = post.errors })
  else
    RedirectTo("/posts/" .. post.data._key .. "/edit")
    SetFlash("success", "Post updated successfully")
  end
end

-- DELETE /posts/:id - Delete a post
function app.delete()
  local post = Post.new():find(Params.id)
  post:destroy()
  RedirectTo("/posts")
  SetFlash("success", "Post deleted successfully")
end

return BeansEnv == "development" and HandleController(app) or app

5 Create the Views

Create the view templates for each action.

list Index View (List all posts)

description app/views/posts/index.etlua
<div class="container mx-auto px-4 py-8">
  <div class="flex justify-between items-center mb-6">
    <h1 class="text-3xl font-bold">Posts</h1>
    <a href="/posts/new" class="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg">
      New Post
    </a>
  </div>

  <div class="space-y-4">
    <% for _, post in ipairs(posts.data or {}) do %>
      <div class="p-4 bg-slate-800 rounded-lg border border-slate-700">
        <a href="/posts/<%= post._key %>" class="text-xl font-semibold text-purple-400 hover:text-purple-300">
          <%= post.title %>
        </a>
        <p class="text-slate-400 mt-2"><%= post.content %></p>
      </div>
    <% end %>
  </div>
</div>

visibility Show View (View a post)

description app/views/posts/show.etlua
<div class="container mx-auto px-4 py-8">
  <div class="max-w-2xl">
    <a href="/posts" class="text-slate-400 hover:text-white mb-4 inline-block">
      ← Back to posts
    </a>

    <h1 class="text-3xl font-bold mb-4"><%= post.title %></h1>
    <p class="text-slate-300 whitespace-pre-wrap"><%= post.content %></p>

    <div class="flex gap-4 mt-8">
      <a href="/posts/<%= post._key %>/edit" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">
        Edit
      </a>
      <form action="/posts/<%= post._key %>" method="POST">
        <%- AuthenticityTokenTag() %>
        <input type="hidden" name="_method" value="DELETE">
        <button type="submit" class="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg" onclick="return confirm('Are you sure?')">
          Delete
        </button>
      </form>
    </div>
  </div>
</div>

add_circle New View (Create form)

description app/views/posts/new.etlua
<div class="container mx-auto px-4 py-8">
  <div class="max-w-2xl">
    <a href="/posts" class="text-slate-400 hover:text-white mb-4 inline-block">
      ← Back to posts
    </a>

    <h1 class="text-3xl font-bold mb-6">New Post</h1>
    <%- Partial("posts/_form", { post = post, errors = errors, action = "/posts" }) %>

  </div>
</div>

edit Edit View (Edit form)

description app/views/posts/edit.etlua
<div class="container mx-auto px-4 py-8">
  <div class="max-w-2xl">
    <a href="/posts" class="text-slate-400 hover:text-white mb-4 inline-block">
      ← Back to post
    </a>

    <h1 class="text-3xl font-bold mb-6">Edit Post</h1>

    <%- Partial("posts/_form", { post = post, errors = errors, action = "/posts/" .. post._key, method = "PUT" }) %>
  </div>
</div>

dynamic_form Form Partial (Reusable form)

description app/views/partials/posts/_form.html.etlua
<form action="<%= action %>" method="POST" class="space-y-6">
  <%- AuthenticityTokenTag() %>
  <% if method then %>
    <input type="hidden" name="_method" value="<%= method %>">
  <% end %>

  <% if errors and #errors > 0 then %>
    <div class="p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
      <ul class="list-disc list-inside text-red-400">
        <% for _, err in ipairs(errors) do %>
          <li><%= EncodeJson(err) %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <label for="title" class="block text-sm font-medium text-slate-300 mb-2">Title</label>
    <input
      type="text"
      name="title"
      id="title"
      value="<%= post.title or '' %>"
      class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
      placeholder="Enter post title..."
    >
  </div>

  <div>
    <label for="content" class="block text-sm font-medium text-slate-300 mb-2">Content</label>
    <textarea
      name="content"
      id="content"
      rows="6"
      class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
      placeholder="Enter post content..."
    ><%= post.content or '' %></textarea>
  </div>

  <button type="submit" class="px-6 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg font-medium">
    Save Post
  </button>
</form>

6 Run Migration

Now run the migration to create the collection in your database:

$ beans db:migrate
check_circle
The posts collection is now created in your ArangoDB database with automatic c_at and u_at timestamp fields.

undo Rollback if needed

If you need to undo the migration:

$ beans db:rollback

folder Final File Structure

app/
├── controllers/
│   └── posts_controller.lua
├── models/
│   └── post.lua
└── views/
    ├── posts/
    │   ├── index.etlua
    │   ├── show.etlua
    │   ├── new.etlua
    │   └── edit.etlua
    └── partials/
        └── posts/
            └── _form.html.etlua
migrations/
└── XXXXXX_add-posts-collection.lua

play_arrow Test It!

Start your development server and visit /posts:

$ beans dev
check_circle

Done!

Visit http://localhost:8080/posts to see your CRUD in action!

lightbulb

Pro Tips

Automatic Timestamps: The c_at and u_at fields are automatically set by ArangoDB Models.

Flash Messages: Use SetFlash("success", "message") to show success/error notifications after redirects.

Data Access: Model methods return objects with .data for the record and .errors for validation errors.

Method Override: HTML forms only support GET/POST. Use <input type="hidden" name="_method" value="PUT"> for PUT/DELETE requests.