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.
What We'll Build
A posts application with:
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:
local migration = {}
function migration.up()
Adb.primary:CreateCollection("posts")
end
function migration.down()
Adb.primary:DeleteCollection("posts")
end
return migration
down() function defines how to rollback the migration.
2 Create the Model
Create the Post model to handle data validation and database operations.
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
3 Define Routes
Add the resource routes to your routes file. One line creates all CRUD routes!
-- Posts
Resource("posts")
route Generated Routes
| Method | Path | Action | Purpose |
|---|---|---|---|
| GET | /posts | index | List all posts |
| GET | /posts/new | new | Show create form |
| POST | /posts | create | Create post |
| GET | /posts/:id | show | View post |
| GET | /posts/:id/edit | edit | Show edit form |
| PUT | /posts/:id | update | Update post |
| DELETE | /posts/:id | delete | Delete post |
4 Create the Controller
Create the controller with all CRUD actions.
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)
<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)
<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)
<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)
<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)
<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
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
Done!
Visit http://localhost:8080/posts to see your CRUD in action!
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.