Controllers
Controllers are the heart of your application. They handle HTTP requests, process data, and determine the response.
Soli supports both modern class-based controllers and simple function-based handlers.
The request hash is automatically available as req — no parameter declaration needed.
When no explicit render is called, the matching view template is auto-rendered
with all @ instance variables exposed as view locals.
Class-Based Controllers
Recommended for most applications. They provide structure, inheritance, and powerful hooks like before_action.
class PostsController < ApplicationController
static {
this.layout = "layouts/posts";
}
def index
@title = "All Posts"
@posts = Post.all
end
def show
@post = Post.find(params["id"]) # raises 404 if not found
end
end
Lifecycle Hooks
Execute code before or after actions. Perfect for authentication or data loading.
this.layout
Set the layout template for all actions
this.before_action
Run code before action executes
this.after_action
Run code after action executes
class PostsController < ApplicationController
static {
# Set layout for all actions
this.layout = "layouts/posts";
# Run before ALL actions
this.before_action = fn(req) {
print("Processing: " + req.path);
req
}
# Run before SPECIFIC actions only
this.before_action(:show, :edit) = fn(req) {
@post = Post.find(params["id"]) # raises 404 if not found
req
}
# Run after SPECIFIC actions
this.after_action(:create, :update) = fn(req, response) {
# Log activity after create/update
print("Action completed");
response
}
}
# ... actions ...
end
No imports needed for models. Files under app/models/ are auto-loaded by soli serve and the REPL, so classes like User, Post, etc. are available inside controller actions without import. (If you run a controller file standalone via soli run, add the imports back.) The linter warns via style/redundant-model-import.
Controller Inheritance
Controllers support multi-level inheritance. Create a base controller to share logic across multiple controllers.
Hooks like before_action, after_action, and layout are automatically inherited by child controllers.
class BaseController < Controller
static {
this.layout = "application";
this.before_action = fn(req) {
user_id = req.session["user_id"];
if user_id != null {
req["current_user"] = User.find(user_id);
}
req
}
}
# Shared helper available to all child controllers
def _current_user
this.req["current_user"]
end
end
# Inherits layout, before_action, and _current_user from BaseController
class PostsController < BaseController
static {
# Add controller-specific hook (runs after parent's before_action)
this.before_action(:show, :edit) = fn(req) {
@post = Post.find(params["id"]) # raises 404 if not found
req
}
}
def index
@posts = Post.all
end
def show
# @post is set by before_action — template renders posts/show
end
end
# Even deeper inheritance works
class AdminController < BaseController
static {
this.layout = "admin"; # Override parent layout
this.before_action = fn(req) {
return redirect("/login") if req["current_user"] == null;
req
}
}
end
Inheritance Rules
- Methods are inherited and can be overridden. Use
super.method()to call the parent version. - before_action / after_action hooks are inherited from parent controllers. Child hooks run after parent hooks.
- layout is inherited if the child doesn't set its own.
- Fields declared in parent classes are available in child instances.
Nested Controller Directories
Group related controllers into subdirectories under app/controllers/. The directory path becomes part of the controller key, the route base path, and the class name.
app/controllers/
├── home_controller.sl # HomeController → /
├── users_controller.sl # UsersController → /users
└── admin/
├── merchants_controller.sl # AdminMerchantsController → /admin/merchants
└── user_profiles_controller.sl # AdminUserProfilesController → /admin/user_profiles
Both _ and / act as word separators in the class name, so admin/user_profiles_controller.sl becomes AdminUserProfilesController — there is no :: namespacing.
Reference nested controllers from config/routes.sl using the same controller#action syntax with a /-separated key:
get("/admin/merchants", "admin/merchants#index")
get("/admin/merchants/:id", "admin/merchants#show")
# Or with resources()
resources("/admin/merchants", "admin/merchants")
Hot reload
Subdirectories are watched recursively in dev mode, so adding or editing a nested controller triggers reload like any top-level controller.
Function-Based Controllers
Great for simple APIs or microservices where you don't need the full power of classes.
# GET /health
def health
{
"status": 200,
"headers": {"Content-Type": "application/json"},
"body": "{\"status\":\"ok\"}"
}
end
The Request Object
Access all HTTP request details through the req parameter.
req.params
Route parameters & query strings
req.body
Raw request body content
req.headers
HTTP headers map
req.session
User session data storage
req.cookies
Parsed cookies from the Cookie header, available globally as cookies
req["remote_addr"]
Actual TCP peer IP. Used by rate_limit for buckets; honored as the trusted client identifier when enable_trust_proxy() is off, otherwise the rightmost X-Forwarded-For entry wins.
Cookies
Response Types
render(template, data?)
Render an HTML view template with optional data.
render("home/index", { "title": "Welcome" })
render_json(data, status?)
Render a JSON response with automatic Content-Type: application/json header. Data is automatically serialized to JSON.
render_json({ "users": users })
render_json({ "error": "Not found" }, 404)
Security — instance serialisation.
render_json(instance) (and any code path that JSON-stringifies a model record) omits sensitive fields by default. Names matching password*, *_token, *_digest, *_secret, or *_hash are dropped, as are _-prefixed framework internals (_errors, _text, …). Standard model metadata (_key, _id, _rev, _created_at, _updated_at) is still included. To expose a field whose name matches a pattern, build the response shape explicitly: render_json({ "id": user._key, "email": user.email }).
For a reusable model-side shape, define an as_json method on the Model subclass — same convention as Rails' ActiveModel::Serializers#as_json:
class User < Model
def as_json
return { "id": this._key, "email": this.email, "name": this.name }
end
end
# controller — render_json auto-dispatches through the user method:
render_json(user)
# equivalent to: render_json(user.as_json())
When render_json receives an Instance whose class declares def as_json, the framework calls the method first and forwards the resulting Hash to render_json. Models without an as_json method fall back to the default-deny filter described above.
render_text(text, status?)
Render a plain text response with automatic Content-Type: text/plain header.
render_text("pong")
render_text("Not allowed", 403)
redirect(url)
Create a redirect response (302 Found) to a local absolute path. External URLs are rejected to prevent open redirects.
redirect("/login")
redirect("/posts/" + post["id"])
Use redirect_external(url) only for trusted external destinations such as OAuth providers.
redirect_external("https://github.com/login/oauth/authorize")
Pass :back to send the user where they came from. The Referer header is honored only when its scheme and host match the current request; external or missing referers fall back to /.
redirect(:back)
halt(status, message)
Return an error response with the given HTTP status code and message.
halt(404, "Page Not Found")
halt(403, "Forbidden")
respond_to(req, block)
Rails-style content negotiation. Picks the right branch based on URL extension, ?format=, HTMX/XHR headers, or the Accept header (with q-values). Falls back to 406 Not Acceptable if nothing matches and no any branch is registered.
def show
post = Post.find(req["params"]["id"])
respond_to(req, fn(format) {
format.html(fn() render("posts/show", { "post": post }))
format.json(fn() render_json(post))
format.csv(fn() render_csv_for(post))
format.pdf(fn() render_pdf_for(post))
format.excel(fn() render_xlsx_for(post))
format.htmx(fn() render("posts/_show_partial", { "post": post }, { "layout": false }))
format.xhr(fn() render_json({ "id": post["id"] }))
format.any(fn() render("posts/show", { "post": post })) # optional catch-all
})
end
A terser hash form is also supported:
respond_to(req, {
"html": fn() render("posts/show", { "post": post }),
"json": fn() render_json(post)
})
Format detection priority
HX-Request: trueheader →htmxbranch.X-Requested-With: XMLHttpRequestheader →xhrbranch.- URL extension:
.html,.json,.xml,.csv,.pdf,.xlsx/.xls,.txt. ?format=…query parameter.Acceptheader — parsed with q-values;*/*falls through to the first registered handler.
Available format tokens
html, json, xml, csv, pdf, excel, htmx, xhr, text, any. Registering any makes it the catch-all (no 406). Last registration wins on duplicates.
Note: header keys in req["headers"] are lowercased — read req["headers"]["accept"], not "Accept".
Request-Context Helpers in Views
These helpers read the current request directly — no need to plumb current_path or current_method through the data hash. They're available in every template (views, layouts, partials).
current_path()
Request pathname, e.g. "/users". null outside a request.
current_method()
HTTP method, e.g. "GET". null outside a request.
current_path?(p)
true if the current path equals p exactly. Use for active-link checks.
<nav>
<a href="/users" class="<%= current_path?("/users") ? "active" : "" %>">Users</a>
<a href="/posts" class="<%= current_path?("/posts") ? "active" : "" %>">Posts</a>
</nav>
<p>You are viewing <%= current_path() %> (<%= current_method() %>).</p>
For prefix matches (e.g. any path under /users), compose with current_path().starts_with("/users").
Pro Tip: Keep Controllers Thin
Delegate complex business logic to Models or Service objects. Controllers should primarily focus on handling the HTTP request and selecting the correct response.
Best Practices
- Use class-based controllers for better organization and reuse.
-
Create a base
ApplicationControllerto share logic like auth across all controllers. -
Use
before_actionto load resources and DRY up your code. -
Prefix private helper methods with
_to prevent them from becoming routes.