Getting started — Install

Panaragan targetsJDK. Build with Maven or Gradle. The runtime reads .env from resources/ or working dir.

Requirements: JDK, JDBC driver (e.g. PostgreSQL), Maven/Gradle
Create & run (Gradle)
./gradlew build
java -jar build/libs/your-app.jar
Create & run (Maven)
mvn package
java -jar target/your-app.jar
Minimal .env
app.name=Panaragan
app.port=8005
app.base_url=http://127.0.0.1:8005/

You can also set Database (Mysql, Postgresql, SQLServer or Oracle), SMTP, Session, Cookies, etc. in .env as used by the sample Web/CMS.

Quick Start

Start Panaragan quickly via three starter packs (Vanilla, CMS, API) or follow the minimal “Vanilla” walkthrough below (environment, Core, routes, controllers, views, run & test).

A) Download a Starter Pack

  1. Open theDownloadpage and choose one:
    • Vanilla (empty)— bare minimum skeleton; you wire everything yourself (covered below).
    • Web/CMS Project— ready-to-run Web/CMS example (routes/controllers/views already set).
    • API Project— ready-to-run API example (JSON endpoints, JWT, etc.).
  2. Click the project to download, then unzip to a working folder.

B) Run the CMS / API Packs (Instant)

  1. Open the unzipped folder in IntelliJ IDEA (or your IDE). Let it import Gradle/Maven.
  2. Run the app (use the mainAppclass or Gradle/Maven run tasks).
  3. Visit http://127.0.0.1:8005/ (or the configured port).

Tip: If a .env is included, update app.port & credentials as needed.

C) Vanilla Pack — Step-by-Step

We’ll add a minimal .env, implement Core, register routes, create two controllers (View + API), add a base layout (Tailwind CDN) + home view, then run and test.

Step 0 — Requirements & Structure

  • JDK, Gradle or Maven.
  • Recommended structure:
    your-app/
      ├─ src/main/java/app/
      │  ├─ App.java                     (implements Core)
      │  └─ controllers/
      │     ├─ Home.java                 (renders view)
      │     └─ Api.java                  (returns JSON)
      └─ src/main/resources/
         ├─ .env
         ├─ public/                      (optional static assets)
         └─ views/
            ├─ layouts/
            │  └─ frontend.html          (base layout - Tailwind CDN)
            └─ frontend/
               └─ home.html              (page)

Step 1 — Minimal .env

Create src/main/resources/.env:

app.name=Panaragan Vanilla
app.port=8005
app.base_url=http://127.0.0.1:8005/

# Templates
template.path=views
template.minify=true

# Sessions & CSRF
session.mode=MEMORY
csrf.name=csrf-token
csrf.expired=3600

# (Optional DB) enable if you need Database.* helpers
# db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
# db.username=postgres
# db.password=postgres

Step 2 — Implement Core & Register Routes

Create src/main/java/app/App.java:

package app;

public class App implements Core {
  public static void main(String[] args) {
    Server.run(new App());
  }

  public void boot(final Routers r) {
    // Routes: one view page, one JSON endpoint
    r.get("/", app.controllers.Home::index);
    r.get("/api/health", app.controllers.Api::health);
  }

  public void accept(final Request req, final Response res) {
    // Default content type; handlers may override.
    res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");
  }

  public boolean authorize(final Request req, final Response res, final String authority) {
    // No guards in this quickstart. If you call res.redirect/empty/send/renderView here,
    // it's terminal (no further code runs).
    return true;
  }

  public void respond(final Request req, final Response res) {
    res.addHeader("X-Frame-Options","DENY");
    res.addHeader("X-Content-Type-Options","nosniff");
    res.addHeader("Referrer-Policy","strict-origin-when-cross-origin");
  }

  public void error(final Request req, final Response res, final String reason) {
    ParamsObject ctx = new ParamsObject()
      .set("error_code",    res.getCode())
      .set("error_message", res.getMessage())
      .set("error_reason",  reason);
    res.renderView("error", ctx);
  }
}

Step 3 — First Controllers (View & API)

Create src/main/java/app/controllers/Home.java:

package app.controllers;

public class Home {
  public static void index(Request request, Response response) {
    // Provide only what the layout/page needs
    ParamsObject data = new ParamsObject()
      .set("page_title", "Welcome")          // used in <title>
      .set("intro", "Build fast. Ship faster.");

    response.renderView("frontend.home", data);
  }
}

Create src/main/java/app/controllers/Api.java:

package app.controllers;

public class Api {
  public static void health(Request req, Response res) {
    PJsonObject json = new PJsonObject()
      .set("ok", true)
      .set("time", System.currentTimeMillis());
    res.send(json); // Content-Type: application/json
  }
}

Step 4 — Base Layout (Tailwind CDN) & Home View

Use Tailwind via CDN to avoid missing local assets. Add both files below.

4.1Create src/main/resources/views/layouts/frontend.html:

<!-- Minimal base layout using Tailwind CDN -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>{{ config('app.name') }} — {{ page_title }}</title>

  <!-- Tailwind CDN -->
  <script src="https://cdn.tailwindcss.com"></script>

  @yield('style')
</head>
<body class="antialiased text-slate-900 bg-slate-50">
  <header class="border-b bg-white/80 backdrop-blur-sm">
    @yield('menus')
  </header>

  @yield('body')

  <footer class="mt-10 text-center text-xs text-slate-500 py-8">
    &copy; <span id="year"></span> Panaragan
  </footer>

  <script>document.getElementById('year').textContent = new Date().getFullYear();</script>
  @yield('script')
</body>
</html>

4.2Create src/main/resources/views/frontend/home.html:

@extends('layouts.frontend')

@section('menus')
  <div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
    <a href="{{ url('/') }}" class="font-semibold">Panaragan</a>
    <nav class="text-sm text-slate-600">
      <a class="px-3 py-2 hover:underline" href="{{ url('/') }}">Home</a>
      <a class="px-3 py-2 hover:underline" href="{{ url('api/health') }}">API</a>
    </nav>
  </div>
@endsection

@section('body')
  <main class="max-w-6xl mx-auto px-6 py-10">
    <h1 class="text-3xl font-bold">Welcome to Panaragan</h1>
    <p class="mt-2 text-slate-600">{{ intro }}</p>
    <div class="mt-6 flex gap-3">
      <a class="px-3 py-2 rounded bg-slate-900 text-white" href="{{ url('api/health') }}">Check API</a>
      <a class="px-3 py-2 rounded border" href="{{ url('docs') }}">Read Docs</a>
    </div>
  </main>
@endsection

Step 5 — Build & Run

From the project root:

# Gradle
./gradlew clean build -x test
java -jar build/libs/*-all.jar   # or the produced fat jar name

# Maven
mvn -q -DskipTests package
java -jar target/*.jar

Step 6 — Test

  • http://127.0.0.1:8005/ → renders frontend.home using the Tailwind CDN layout.
  • http://127.0.0.1:8005/api/health → returns JSON {"ok":true,...}.

Notes & Troubleshooting

  • Blank page?Ensure you have layouts/frontend.html and your page uses @extends('layouts.frontend') with a @section('body').
  • Template not found?Check template.path=views and file paths under src/main/resources/views/....
  • Port in use?Change app.port in .env or free the port.
  • JSON shows as HTML?Ensure Core.accept sets JSON for /api/* paths or call response.setContentType("application/json") before send(...).
What’s next?

Features at a Glance

Core lifecycle
Implement Core: boot, accept, authorize, respond, error.
Routing
Fast routes, groups, param segments /x/{id}, GET/POST, mixed getOrPost.
Templates
Blade-like sections, components, minify, auto CSRF helper.
Request/Response
Typed getters, JSON, redirects, flashes, file & byte responses.
DB + Builder
JDBC + QueryBuilder for SELECT, Database for CRUD.
Datatable
Server-side filtering, ordering, pagination, custom renderers.
Security
CSRF token, secure headers, session modes, cookies helper.
Jobs
Every second/minute/hour/day; run background tasks easily.
Email
SMTP + HTML templates + attachments + multi-config selection.
i18n
JSON-based languages under resources/languages/.
Uploads
Save files with content-type checks; temp store to disk.
URL helper
Url.to("/path"), query helpers, safe encoding/decoding.
Core

Core is your application lifecycle contract. Implement it once to centralize routing bootstrap, per-request setup, authorization checks, cross-cutting headers, and unified error rendering. Mastering these hooks helps you keep controllers lean and your app consistent.

Lifecycle (execution order)

  1. boot(routers)— runs once at startup; register routes here.
  2. accept(request, response)— runs onevery requestbefore the controller; add defaults & context.
  3. authorize(request, response, authority)— runs if the matched route/group set an authority string.
  4. controller— your handler logic.
  5. respond(request, response)— last stop before flush; add final headers/content-type.
  6. error(request, response, reason)— centralized error rendering for exceptions/error codes.
Terminal responses:calling response.renderView(...), response.send(...), response.redirect(...), response.redirectBack(...), response.redirectBackWithInput(...), or response.empty(...) immediatelyterminatesthe pipeline. That means inside authorize() you don’t need to return anything after a redirect/send/empty — the flow stops there.

Minimal Core implementation

package app;

public class App implements Core {
  public static void main(String[] args) {
    Server.run(new App());
  }

  public void boot(final Routers r) {
    // Basic routes: one HTML view and one JSON endpoint
    r.get("/", app.controllers.Home::index);
    r.get("/api/health", app.controllers.Api::health);
  }

  public void accept(final Request req, final Response res) {
    // Set a sensible default content-type; controllers may override.
    res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");

    // Example: attach a per-request id for tracing
    String rid = java.util.UUID.randomUUID().toString();
    req.setPayload("req_id", rid);
    res.addHeader("X-Request-ID", rid);
  }

  public boolean authorize(final Request req, final Response res, final String authority) {
    // No guards here in the minimal example.
    // NOTE: If you call res.redirect()/res.empty()/res.send()/res.renderView() here,
    // the request terminates immediately; you don't need to return anything after that.
    return true;
  }

  public void respond(final Request req, final Response res) {
    // Security/compat headers (good defaults)
    res.addHeader("X-Frame-Options", "DENY");
    res.addHeader("X-Content-Type-Options", "nosniff");
    res.addHeader("Referrer-Policy", "strict-origin-when-cross-origin");
  }

  public void error(final Request req, final Response res, final String reason) {
    // Unified HTML error page
    ParamsObject ctx = new ParamsObject()
      .set("error_code",    res.getCode())
      .set("error_message", res.getMessage())
      .set("error_reason",  reason);
    res.renderView("error", ctx); // terminal
  }
}

Defining routes in boot() (palette)

Register single-verb routes, multi-verb utilities, and groups with optional authority:

// Single verb
r.get("/", Home::index);
r.post("/login", Auth::submit, "guest");
r.put("/users/{id}", UsersUpdate::update, "users_update");
r.delete("/users/{id}", UsersDelete::remove, "users_delete");
r.patch("/profile", Profile::partialUpdate, "profile_edit");
r.head("/files/{id}", Files::headInfo);
r.options("/api/*", Cors::preflight);

// Multi-verb utilities
r.getOrPost("/search", Search::index);   // GET and POST
r.all("/webhook/{source}", Webhook::receive);

// Grouping (prefix) + authority cascade to children (unless overridden)
r.group("/admin", "auth", g -> {
  g.get("/", Admin::dashboard);                     // requires "auth"
  g.get("roles", Roles::index, "roles_list");       // overrides to "roles_list"
});

Dynamic segments use {name}; read with request.getParam("name"). Wildcards like /api/* match nested paths.

Patterns for accept() (per-request setup)

// A) Default content-type, session user to payload, locale prep
public void accept(final Request req, final Response res) {
  res.setContentType(req.getPath().startsWith("/api/") ? "application/json" : "text/html");

  String userJson = req.getSession("user");            // set during login
  if (userJson != null) {
    req.setPayload("user", PJsonObject.parse(userJson));
  }

  // Optional: language detection/bootstrap (if your app uses Language)
  req.getLanguage(); // resolves from Accept-Language or default

  // Request id
  String rid = java.util.UUID.randomUUID().toString();
  req.setPayload("req_id", rid);
  res.addHeader("X-Request-ID", rid);
}
// B) Bearer/JWT extraction (pseudo; implement with your JWT library)
public void accept(final Request req, final Response res) {
  String auth = req.getHeader("Authorization", "");
  if (auth.startsWith("Bearer ")) {
    String token = auth.substring(7);
    try {
      PJsonObject claims = Jwt.verifyAndDecode(token); // your lib/impl
      req.setPayload("jwt", claims);
    } catch (Exception ignored) {
      // invalid token → treat as guest
    }
  }
}

Recipes for authorize()

A route (or its group) may set an authority string (e.g., "guest", "auth", "users_list"). In authorize() you decide what that string means. Remember: any terminal response here stops the pipeline immediately.

// Guest-only vs Auth-only
public boolean authorize(final Request req, final Response res, final String a) {
  boolean loggedIn = (req.getSession("user") != null);

  if ("guest".equals(a)) {
    if (loggedIn) { res.redirect("dashboard"); }           // terminal; no return needed after this line
    return true;
  }

  if ("auth".equals(a)) {
    if (!loggedIn) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal
    return true;
  }

  return true;
}
// RBAC permission strings stored in session
public boolean authorize(final Request req, final Response res, final String perm) {
  if (perm == null || perm.isBlank()) return true;

  String userJson = req.getSession("user");
  if (userJson == null) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal

  PJsonArray perms = (PJsonArray) PJsonObject.parse(userJson).get("permissions");
  if (perms == null || !perms.contains(perm)) { res.empty(403, "Forbidden"); }       // terminal

  return true;
}
// CORS preflight guarded by a custom authority
// boot(): r.options("/api/*", Cors::preflight, "cors");
public boolean authorize(final Request req, final Response res, final String a) {
  if ("cors".equals(a) && "OPTIONS".equals(req.getMethod())) {
    res.addHeader("Access-Control-Allow-Origin",  "*");
    res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS");
    res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.empty(204, "No Content"); // terminal
  }
  return true;
}
// IP allow-list / API key
public boolean authorize(final Request req, final Response res, final String a) {
  if ("internal_api".equals(a)) {
    String ip  = req.getIP();
    String key = req.getHeader("X-API-Key", "");
    if (!"127.0.0.1".equals(ip) || !"supersecret".equals(key)) { res.empty(401, "Unauthorized"); } // terminal
  }
  return true;
}

Patterns for respond()

// Security & defaults (good baseline)
public void respond(final Request req, final Response res) {
  res.addHeader("X-Frame-Options", "DENY");
  res.addHeader("X-Content-Type-Options", "nosniff");
  res.addHeader("Referrer-Policy", "strict-origin-when-cross-origin");

  // Force JSON for /api/*
  if (req.getPath().startsWith("/api/")) {
    res.setContentType("application/json");
  }
}
// Public cache for homepage, CORS headers for APIs
public void respond(final Request req, final Response res) {
  if ("/".equals(req.getPath())) {
    res.addHeader("Cache-Control", "public, max-age=60");
  }
  if (req.getPath().startsWith("/api/")) {
    res.addHeader("Access-Control-Allow-Origin",  "*");
    res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS");
    res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  }
}

Patterns for error()

// HTML error page (default)
public void error(final Request req, final Response res, final String reason) {
  ParamsObject ctx = new ParamsObject()
    .set("error_code",    res.getCode())
    .set("error_message", res.getMessage())
    .set("error_reason",  reason);
  res.renderView("error", ctx); // terminal
}
// JSON errors for API paths
public void error(final Request req, final Response res, final String reason) {
  if (req.getPath().startsWith("/api/")) {
    res.send(new PJsonObject()
      .set("error",   true)
      .set("code",    res.getCode())
      .set("message", res.getMessage())
      .set("reason",  reason));
    return; // explicit only
  }
  ParamsObject ctx = new ParamsObject()
    .set("error_code",    res.getCode())
    .set("error_message", res.getMessage())
    .set("error_reason",  reason);
  res.renderView("error", ctx);
}
Example views/error.html (escaped so it won’t render):
@extends('layouts.frontend')
@section('body')
  <section class="py-16">
    <div class="max-w-xl mx-auto px-6">
      <h1 class="text-2xl font-bold">Error {{ error_code }}</h1>
      <p class="mt-2 text-slate-600">{{ error_message }}</p>
      <pre class="mt-4 text-xs bg-slate-100 p-3 rounded">{{ error_reason }}</pre>
      <a class="inline-block mt-6 px-3 py-2 bg-slate-900 text-white rounded" href="{{ url('/') }}">Back to home</a>
    </div>
  </section>
@endsection

End-to-end: Admin & Users

// boot()
r.group("/admin", "auth", g -> {
  g.get("/", Admin::dashboard);                               // "auth" guard
  g.group("users", "users_list", ug -> {
    ug.get("/", UsersIndex::index);                           // needs "users_list"
    ug.post("store", UsersAdd::store, "users_add");           // needs "users_add"
    ug.get("edit/{id}", UsersEdit::index, "users_edit");      // needs "users_edit"
  });
});

// authorize()
public boolean authorize(final Request req, final Response res, final String perm) {
  if (perm == null || perm.isBlank()) return true;

  String userJson = req.getSession("user");
  if (userJson == null) { res.redirect("login", "callback_url", req.getFullUrl()); } // terminal

  PJsonObject user = PJsonObject.parse(userJson);
  if ("auth".equals(perm)) return true; // already authenticated

  PJsonArray perms = (PJsonArray) user.get("permissions");
  if (perms == null || !perms.contains(perm)) { res.empty(403, "Forbidden"); } // terminal
  return true;
}

// respond(): add API headers if needed; see patterns above
// error(): same as the examples above

Troubleshooting (Core)

  • Authorize seems ignored:ensure the route or its group sets an authority string.
  • Unexpected HTML on API errors:switch on path prefix (or Accept header) in error()/respond() to send JSON.
  • Redirect loops:verify how/when you set/destroy session("user") and double-check guards ("guest"/"auth").
  • Headers missing:add them in respond(); after a terminal call the response is already flushed.
  • OPTIONS/HEAD issues:handle them explicitly via r.options(...) and r.head(...) and terminate with empty(...).
See also
Cache

A lightweight, thread-safe, in-memory cache for speeding up repeated computations, DB/API calls, or rendering pre-built results. Keys are strings (internally hashed with XXH64), and each entry has a per-item TTL (time-to-live).

Public API

  • Cache.set(String key, Object value) — store using thedefault TTL.
  • Cache.set(String key, Object value, int ttlMs) — store with a custom TTL (ms).
  • Cache.get(String key) — get ifnot expired; returns null if missing/expired.
  • Cache.getFromExpired(String key) — get even if expired (useful for “stale-while-revalidate”).
  • Cache.delete(String key) — remove a key manually.
  • Cache.setMaxExpired(int ttlMs) — set the global default TTL (non-negative).
  • Cache.setMaxItems(int max) — set global max entries (min threshold enforced). Auto-sweeps when near capacity.
Defaults (from source):default TTL ≈ 10_000 ms; capacity guard ≈ 10_000_000 items (automatic sweep of expired entries when size ≥ ~80% of the limit). In-memory only (not distributed).

Basics

// Set & get with default TTL
Cache.set("home:hero", "Welcome to Panaragan!");
String hero = (String) Cache.get("home:hero"); // "Welcome to Panaragan!" or null if expired

// Set with custom TTL (e.g., 30 seconds)
Cache.set("report:weekly", generateWeeklyReport(), 30_000);

// Delete manually
Cache.delete("home:hero");

// Adjust global defaults (optional)
Cache.setMaxExpired(15_000); // new default TTL for subsequent Cache.set(key, value)
Cache.setMaxItems(2_000_000); // adjust capacity guard

Avoid heavy recomputation

String key = "stats:dashboard:v1";
PJsonObject cached = (PJsonObject) Cache.get(key);
if (cached == null) {
  long t0 = System.currentTimeMillis();
  cached = StatsService.computeDashboard(); // expensive: DB + external API
  Cache.set(key, cached, 60_000);           // cache for 60s
  System.out.println("fresh compute: " + (System.currentTimeMillis()-t0) + " ms");
} else {
  System.out.println("served from cache");
}
response.send(cached); // terminal JSON

Key design (namespaces & parameters)

Build stable, descriptive keys. Include versions and parameters so you can invalidate them predictably.

// Good patterns
String key1 = "user:profile:v2:" + userId;
String key2 = String.format("search:v1:q=%s&page=%d", query, page);

// Controller sample
PJsonObject profile = (PJsonObject) Cache.get(key1);
if (profile == null) {
  profile = UserRepo.fetchProfile(userId);
  Cache.set(key1, profile, 300_000); // 5 minutes
}
response.renderView("users.profile", new ParamsObject()
  .set("page_title","Profile")
  .set("profile", profile)); // terminal

Stale-while-revalidate (SWR)

Serve a stale value immediately (better latency) while refreshing in the background. Use getFromExpired to fall back to expired data if the fresh entry is missing.

String key = "catalog:homepage:v3";
PJsonObject fresh = (PJsonObject) Cache.get(key); // valid only if not expired
if (fresh != null) {
  response.send(fresh); // terminal
}

// No fresh value; try stale (expired) to avoid blank states
PJsonObject stale = (PJsonObject) Cache.getFromExpired(key);
if (stale != null) {
  response.send(stale); // terminal — quick response
}

// Nothing at all → compute synchronously, then cache
PJsonObject rebuilt = CatalogService.buildHomepage();
Cache.set(key, rebuilt, 45_000);
response.send(rebuilt); // terminal

QueryBuilder integration (auto-cache + DB invalidation)

QueryBuilder.results() transparently caches SELECT results by query+params. When you later call Database.insert/update/delete(...) on a table, Panaragan removes any related cached result sets for that table. You get correctness without manually wiring keys.

// Read side: SELECT is cached automatically by query+params
PJsonArray users = QueryBuilder
  .table("users")
  .select("id","username","email","status")
  .where("status = ?", 1)
  .orderBy("created_at DESC")
  .results(); // served from Cache on subsequent identical requests

// Write side: these calls trigger invalidations for related tables
Database.insert("users", new ParamsObject().set("username","john").set("email","j@ex.com").set("status",1));
Database.update("users", new ParamsObject().set("status",0), "id = ?", 123);
Database.delete("users", "id = ?", 456);

// After writes, future .results() will compute fresh data again
How it works:QueryBuilder records which tables appear in a result set (from metadata) and maps them to the internal cache keys. On writes, Database looks up those keys and calls Cache.delete(...) for you. You can still use manual Cache.set/get for non-SQL workloads.

Per-user / per-scope caches

// Scope cache by user/session/role to avoid cross-user leakage
String uname = request.getSession("username"); // set at login
String key   = "dash:v1:user=" + (uname==null?"guest":uname);

PJsonObject dto = (PJsonObject) Cache.get(key);
if (dto == null) {
  dto = DashboardService.buildForUser(uname);
  Cache.set(key, dto, 120_000);
}
response.send(dto); // terminal

Invalidation patterns

  • Write-through (manual):when you update a resource, Cache.delete("ns:id") the related keys.
  • Versioned keys:bump a version suffix (e.g., v4) when your data shape changes.
  • Time-based:pick TTLs aligned with data volatility (seconds for dashboards, minutes for catalogs).
  • Auto-invalidation (SQL):rely on QueryBuilder + Database coupling for SELECT cache keys.

Pitfalls & guidance

  • Memory-only:cache resets on restart. For shared/distributed caches, wrap Panaragan’s cache with Redis or similar.
  • Right TTL:too long → stale data; too short → little benefit. Start with 30–120s for dynamic dashboards; hours for static lists.
  • Key hygiene:always namespace (e.g., user:profile:v2:{id}). Avoid collisions.
  • Object size:store compact DTOs (e.g., PJsonObject, small POJOs). Avoid huge blobs to prevent GC pressure.
  • Concurrency:the cache is thread-safe. Still, typicalcompute-if-absentpattern is fine (double-check & set).
  • SWR correctness:if you serve stale data, ensure the UI marks it (e.g., “updating…”) and refresh soon after.

Mini reference (copy-paste)

// 1) Basic
Cache.set("foo", 123);
Integer v = (Integer) Cache.get("foo");

// 2) Custom TTL
Cache.set("foo", 123, 5_000); // 5s

// 3) SWR
Object fresh = Cache.get("foo");
if (fresh == null) {
  Object stale = Cache.getFromExpired("foo");
  if (stale != null) { /* use stale; revalidate */ }
}

// 4) Delete & globals
Cache.delete("foo");
Cache.setMaxExpired(20_000);
Cache.setMaxItems(500_000);

// 5) Heavy computation wrapper
String key = "calc:abc";
Object out = Cache.get(key);
if (out == null) { out = doHeavyThing(); Cache.set(key, out, 60_000); }

Troubleshooting

  • Always null:check TTLs; maybe the default TTL (10s) is too short for your use case.
  • Growing memory:lower setMaxItems(...), reduce object sizes, or manually delete coarse keys after batch jobs.
  • Stale after writes:if not using QueryBuilder.results(), delete affected keys after updates.
  • Wrong user’s data:scope keys by user/session/tenant.
See also
ExploreDatabase(auto-invalidation for SELECTs) andDatatablefor server-side lists.
Config

Config is a thin wrapper around an in-memory java.util.Properties map that is populated at server bootstrap from a .env file. Once loaded, values are kept in memory and used across the application. There isno runtime reloadandno OS env overridein the core—edit .env and restart the app to apply changes.

Where values are loaded from

  1. Classpath: resources/.env (if present).
  2. Working directory: ./.env (if present).

The server’s static initializer attempts classpath first, then the working directory. Missing/invalid keys may be normalized, set to defaults, or removed (see notes below).

API (typed getters)

// Strings
String name    = Config.get("app.name", "Panaragan");
String baseUrl = Config.get("app.base_url", "http://127.0.0.1:8005/");

// Numbers & booleans (type inferred from fallback)
int     port   = Config.get("app.port", 8005);
boolean gzip   = Config.get("response.gzip", true);
double  rate   = Config.get("payments.rate", 0.05D);
float   ratio  = Config.get("feature.ratio", 0.75f);

Minimal .env example

# --- Application ---
app.name=Panaragan
app.environment=development    # "development" or "production" (affects internal DEBUG flag)
app.port=8005
app.base_url=http://127.0.0.1:8005/

# Paths (optional; server validates existence and permissions)
app.path_public=public         # folder to serve static files if enabled
app.path_upload=storage/upload # folder to store uploads
app.slug_upload=/uploads       # public slug to expose upload folder (required if path_upload is set)

# Templates
template.path=views
template.minify=false          # evaluated at bootstrap; not changeable at runtime

# HTTP behavior
response.gzip=true
request.max_size_on_mb=20

# Rate limiting
request.rate_limit_ip=yes
request.rate_limit_per_minute=120

# CSRF
csrf.name=_secure-csrf-token
csrf.expired=3600              # seconds

# Sessions
session.mode=MEMORY            # MEMORY | STORAGE | DATABASE | CUSTOM
session.key=_secure_SESSION_ID
session.expired=3600           # seconds
session.path=/
session.domain=
session.http_only=true
session.secure=false
session.same_site=Lax
session.storage=storage/sessions  # used by STORAGE/DATABASE/CUSTOM (see notes)

# Database (single)
# db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
# db.username=postgres
# db.password=postgres
# db.max_pool_size=5
# db.reconnect=5

# Database (multi: db1.*, db2.*, ...)
# db1.url=...
# db1.username=...
# db1.password=...
# db1.max_pool_size=5
# db1.reconnect=5

# SMTP (multi: smtp1.*, smtp2.*, ...)
# smtp1.host=smtp.example.com
# smtp1.port=587
# smtp1.username=user@example.com
# smtp1.password=secret
# smtp1.from=Panaragan
# smtp1.start_ssl=false

# Encryption (used by CIPHER and PJWT)
encrypt.algorithm=AES
encrypt.key=please-change-me-16+

Important keys & behavior (what the server does)

  • app.environment sets an internalDEBUGflag (development ⇒ debug; production ⇒ non-debug).
  • app.path_public and app.path_upload are validated (existence, readability; writability for uploads). If invalid, they are disabled and removed from the runtime properties.
  • app.slug_upload is required to expose the upload folder publicly; if missing/invalid, the upload path won’t be routable.
  • template.minify is readonceat bootstrap. Minified templates are cached; this is not a toggle you can flip at runtime.
  • response.gzip enables gzip compression when sending responses (server-level).
  • Rate limiting uses request.rate_limit_ip and request.rate_limit_per_minute (numeric cap per IP).
  • csrf.name defines the token field/header name; csrf.expired controls token TTL (seconds).
  • session.* controls cookie attributes and the backing store:
    • MEMORY: in-memory store.
    • STORAGE: file-based store (uses session.storage as folder path).
    • DATABASE: DB-backed store (uses session.storage as DSN/class hint; see your setup).
    • CUSTOM: fully qualified class name in session.storage implementing Session.Store.
  • Database configuration supports a single DSN (db.*) or multiple (db1.*, db2.*, ...). Each entry accepts url, username, password, max_pool_size, reconnect.
  • SMTP supports multiple entries (smtp1.*, smtp2.*, ...) with host, port, username, password, from, start_ssl.
  • encrypt.algorithm and encrypt.key are used to initialize symmetric CIPHERs and PJWT. If invalid, the server attempts to select a supported algorithm and key length.
  • Some values are normalized back into the properties (e.g., booleans as true/false, derived paths). You may see the server printSET/NOT SET/WARNINGmessages at startup.

Using config in controllers & views

// Controller → pass values to the view via ParamsObject
public final class HomeController {
  public static void index(Request req, Response res) {
    ParamsObject data = new ParamsObject()
      .set("page_title", "Home")
      .set("app_name",   Config.get("app.name", "Panaragan"))
      .set("base_url",   Config.get("app.base_url", "http://127.0.0.1:8005/"));
    res.renderView("frontend.home", data); // terminal
  }
}
<!-- frontend layout snippet (escaped) -->
<title>{{ app_name }} — {{ page_title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@3.4.10/dist/tailwind.min.css">

Validation & normalization examples

  • If app.path_public is invalid or unreadable, public-folder routing is disabled and the key is removed.
  • If app.path_upload is valid but app.slug_upload is blank/invalid, upload routing is disabled.
  • Session cookie attributes are normalized into true/false, and SameSite is one of Lax, Strict, or None.

Copy-paste reference

// String with default
String app = Config.get("app.name", "Panaragan");

// Numbers & booleans
int  port   = Config.get("app.port", 8005);
long maxSz  = 1024L * 1024L * Config.get("request.max_size_on_mb", 20);
boolean gz  = Config.get("response.gzip", true);

// In a controller
ParamsObject data = new ParamsObject()
  .set("page_title", "Settings")
  .set("env",        Config.get("app.environment", "development"))
  .set("csrf_name",  Config.get("csrf.name", "_secure-csrf-token"));
res.renderView("frontend.settings", data); // terminal

Troubleshooting

  • Changes not applied:the core reads .env at startup—restart the app.
  • “NOT SET / WARNING” at startup:the server failed to validate a key (path not accessible, missing dependency, etc.). Fix the value and restart.
  • Template minify seems ignored:remember it’s evaluated once at bootstrap; there is no runtime toggle.
  • Upload routes don’t work:ensure both app.path_upload and app.slug_upload are set and valid.
See also
Cookies

Cookies let you persist small key–value data on the client (browser), sent on each request via the Cookie header. In Panaragan, you read cookies from Request (with typed getters + defaults) and set cookies with Response.setCookie(...) (writes a Set-Cookie header). Use them for sessions, short-lived preferences (e.g., theme), or “remember me” tokens.

API at a glance

// Read (typed with default)
String  sid     = request.getCookie("session_id");             // String (null if missing)
String  theme   = request.getCookie("theme", "light");         // String default
boolean logged  = request.getCookie("logged_in", false);       // boolean
int     visits  = request.getCookie("visits", 0);              // int
long    ts      = request.getCookie("ts", 0L);                 // long
float   ratio   = request.getCookie("ratio", 0.0f);            // float
double  score   = request.getCookie("score", 0.0D);            // double

// Write (raw Set-Cookie string; one call per cookie)
response.setCookie("theme=dark; Path=/; Max-Age=31536000; SameSite=Lax");
response.setCookie("session_id=xyz; Path=/; HttpOnly; Secure; SameSite=Strict");

// Delete (expire immediately)
response.setCookie("theme=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT");

Reading cookies

Cookies are parsed from the incoming Cookie header. Use typed getters to avoid manual parsing/casting.

public final class Landing {
  public static void index(Request req, Response res) {
    String theme = req.getCookie("theme", "light");
    int    seen  = req.getCookie("visits", 0);

    ParamsObject data = new ParamsObject()
      .set("page_title", "Welcome")
      .set("theme", theme)
      .set("seen", seen + 1);

    res.renderView("frontend.home", data); // terminal
  }
}

Writing cookies

Build a proper Set-Cookie string: include at least Path=/, and for sensitive data add HttpOnly + Secure. Consider SameSite to control cross-site requests.

// Session cookie (clears on browser close)
response.setCookie("session_id=" + UUID.randomUUID() + "; Path=/; HttpOnly; Secure; SameSite=Strict");

// Persistent preference (1 year)
response.setCookie("theme=dark; Path=/; Max-Age=31536000; SameSite=Lax");

// Cross-subdomain cookie (e.g., for *.example.com)
response.setCookie("ab=1; Path=/; Domain=.example.com; Max-Age=86400; SameSite=Lax");

// Delete cookie (match Path/Domain)
response.setCookie("theme=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT");

Common recipes

1) Login session (HttpOnly)

public final class Auth {
  public static void submit(Request req, Response res) {
    String email = req.getPost("email", "");
    String pass  = req.getPost("password", "");

    if (!UserService.check(email, pass)) {
      res.redirectBackWithInput("error", "Invalid credentials"); // terminal
    }

    // Issue a new session id and persist user session server-side
    String sid = SessionService.createFor(email);
    res.setCookie("session_id=" + sid + "; Path=/; HttpOnly; Secure; SameSite=Strict");

    res.redirect("dashboard", "success", "Welcome back!"); // terminal
  }
}

2) Remember-me (persistent)

// On login (opt-in)
if (req.getPost("remember", false)) {
  // token maps to user server-side; rotate token on use
  String token = RememberMe.issue(email);
  // For cross-site SSO, consider SameSite=None; Secure
  res.setCookie("remember="+token+"; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax");
}

3) Theme preference (client-side)

// Toggle dark/light (GET /theme/{mode})
public static void toggle(Request req, Response res) {
  String mode = req.getParam("mode", "light");
  if (!mode.equals("light") && !mode.equals("dark")) mode = "light";
  res.setCookie("theme=" + mode + "; Path=/; Max-Age=31536000; SameSite=Lax");
  res.redirectBack(); // terminal
}

In your layout, pass the theme via controller and render a class on the <body>:

<!-- layout snippet (escaped) -->
<body class="antialiased {{ theme == 'dark' ? 'bg-slate-900 text-white' : 'bg-white text-slate-900' }}">
  @yield('body')
</body>

4) Logout (expire cookie + destroy server session)

public static void logout(Request req, Response res) {
  SessionService.destroy(req.getCookie("session_id"));
  res.setCookie("session_id=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict");
  res.redirect("login", "success", "Signed out"); // terminal
}

Security checklist

  • HttpOnly: prevents JavaScript access (mitigates XSS data theft).
  • Secure: cookie only sent over HTTPS; required when SameSite=None.
  • SameSite: Strict (most safe), Lax (forms/GET ok), None (cross-site; must pair with Secure).
  • Prefix: consider __Host- (must be Secure, no Domain, Path=/) or __Secure- for stricter browsers.
  • Scope: specify Path=/ and if needed Domain=.example.com for subdomains.
  • Size: individual cookie ~4KB limit; keep values compact (ids/tokens, not blobs).
  • Rotation: rotate session/remember tokens regularly; invalidate on logout/password change.

Tips & pitfalls

  • Multiple cookies:call response.setCookie(...) once per cookie you set.
  • Attributes must match to delete:delete with the same Path/Domain originally used.
  • Redirects preserve Set-Cookie:you can set a cookie and immediately redirect(...); the browser stores it.
  • Cross-site SSO:iframes/third-party flows often need SameSite=None; Secure, plus CORS on your endpoints.

Copy-paste reference

// READ
String theme = request.getCookie("theme", "light");
boolean ok   = request.getCookie("ok", false);
int n        = request.getCookie("n", 0);

// WRITE
response.setCookie("k=v; Path=/; Max-Age=3600; SameSite=Lax");
response.setCookie("sid="+UUID.randomUUID()+"; Path=/; HttpOnly; Secure; SameSite=Strict");

// DELETE
response.setCookie("k=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT");

Troubleshooting

  • Cookie not set:ensure you’re not writing headers after a terminal call; set cookiesbeforesend/renderView/empty/redirect.
  • Delete doesn’t work:match the original Path/Domain; otherwise the browser keeps the old cookie.
  • Missing across HTTPS:you set Secure but tested over http://; use https:// or remove Secure locally.
  • Not sent on cross-site:you may need SameSite=None; Secure for third-party flows.
See also
ReviewSession,CSRF, andRequest / Responsefor related patterns.
CSRF

Panaragan menyediakan proteksi CSRF berbasisper-session tokenyangkedaluwarsa. Token disimpan di memori server, memiliki TTL dari .env, dan dicek otomatis untuk form POST melalui utilitas Request.validatePost(...). Untuk endpoint JSON/API, gunakan CSRF.validateRequest(req) secara manual atau kirim token pada header dengan nama yang dikonfigurasi.

Konfigurasi terkait

  • csrf.name — nama parameter/HTTP header token (default: _secure-csrf-token).
  • csrf.expired — TTL token dalam detik (default: 3600 detik). Nilai ini dibaca saat bootstrap.

API yang tersedia

  • CSRF.HTML(Request request) → menghasilkan elemen <input type="hidden"> untuk form.
  • CSRF.validateRequest(Request request)boolean; cek token dari POST paramatauheader.
  • CSRF.getTokenName() → nama token (sama dengan csrf.name yang aktif).
  • CSRF.getTokenValue(Request request) → ambil/generate nilai token untuk session aktif.
  • request.getCSRFTokenName(), request.getCSRFTokenValue() → helper pada Request.
  • request.validatePost(rules [, labels [, messages]]) → otomatiscek CSRFsebelum validasi field.
Catatan perilaku:token pertama kali dibuat saat dibutuhkan (mis. saat merender CSRF.HTML(...) atau saat validasi memerlukan token). Token disapu berkala setiap menit oleh scheduler internal. Jika token tidak valid, validatePost(...) langsung menghasilkan false (validasi field tidak dijalankan).

Form HTML (direkomendasikan)

Sisipkan hidden input CSRF di dalam form menggunakan helper.(Escaped agar tidak ter-render di docs)

<form action="/profile/update" method="post">
  @csrf

  <input type="text" name="full_name" class="input">
  <button type="submit" class="btn">Save</button>
</form>

Controller (POST) dengan validatePost(...)

validatePost(...) akan mengecek CSRF lebih dulu. Jika token tidak valid atau field gagal validasi, method ini mengembalikan false dan kamu bisa redirectBackWithInput(...).

public final class ProfileController {
  public static void update(Request req, Response res) {
    // Rules contoh (gunakan sesuai validator di project kamu)
    ParamsString rules = new ParamsString()
      .set("full_name", "required|min:3|max:100");

    if (!req.validatePost(rules)) {
      // Jika token CSRF invalid atau field tidak valid → kembali dengan input & flash
      res.redirectBackWithInput("error", "Invalid CSRF token or invalid form fields");
      return; // terminal di atas akan stop alur
    }

    // ... lakukan update ...
    res.redirect("/profile", "success", "Updated");
  }
}

Endpoint JSON / AJAX

Untuk request non-form (AJAX/JSON), kirim token padaheaderbernama csrf.name aktif. Di server, panggil CSRF.validateRequest(req) sebelum memproses.

public final class ApiProfile {
  public static void update(Request req, Response res) {
    if (!CSRF.validateRequest(req)) {
      res.empty(403, "CSRF token mismatch"); // terminal
      return;
    }
    // ... proses JSON ...
    PJsonObject out = new PJsonObject().set("ok", true);
    res.send(out); // terminal JSON
  }
}

Mengambil nama & nilai token untuk front-end

Kamu bisa menyisipkan keduanya ke halaman supaya JS dapat mengirim header dengan benar.(Escaped)

<script>
  const CSRF_NAME  = "{{ request.getCSRFTokenName() }}";
  const CSRF_TOKEN = "{{ request.getCSRFTokenValue() }}";
</script>

Contohfetchdi browser

async function saveProfile(data) {
  const headers = {};
  headers[CSRF_NAME] = CSRF_TOKEN; // penting: nama header mengikuti csrf.name

  const res = await fetch('/api/profile/update', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...headers },
    body: JSON.stringify(data)
  });
  if (!res.ok) { throw new Error('CSRF/validation error'); }
  return await res.json();
}

Akses token di controller

public final class TokenController {
  // Menyediakan endpoint untuk SPA mengambil token (opsional)
  public static void show(Request req, Response res) {
    PJsonObject out = new PJsonObject()
      .set("name",  req.getCSRFTokenName())
      .set("token", req.getCSRFTokenValue());
    res.send(out); // terminal
  }
}

Perilaku & siklus hidup token

  • Per session:satu token aktif per session id; dibuat saat dibutuhkan.
  • TTL:ditentukan oleh csrf.expired (detik) → setelah lewat, token diputar/refresh.
  • Sumber token di request:diambil dariPOST parambernama csrf.name, atau jika kosong, darirequest headerbernama sama.
  • Pembersihan periodik:token yang kedaluwarsa dibersihkan oleh job internal setiap menit.

Copy-paste reference

// View
@csrf

// Controller: form POST
if (!req.validatePost(new ParamsString().set("title","required"))) {
  res.redirectBackWithInput("error", "Invalid CSRF token or invalid form fields");
  return;
}

// Controller: JSON/API
if (!CSRF.validateRequest(req)) { res.empty(403, "CSRF token mismatch"); return; }

// Ambil nama+token
String name  = req.getCSRFTokenName();
String token = req.getCSRFTokenValue();

Troubleshooting

  • Token selalu tidak valid:pastikan form menyertakan @csrfdi dalamtag <form>, dan method=POST.
  • AJAX gagal:kirim header bernama persis csrf.name aktif dan isinya request.getCSRFTokenValue().
  • Token berubah sering:cek nilai csrf.expired; TTL terlalu pendek akan memaksa rotasi cepat.
  • Multi-tab/login ulang:session id berbeda → token berbeda; refresh halaman agar JS mengambil token terbaru.
See also
LihatRequest / Responseuntuk pola redirect & error, danValidationuntuk aturan field.
Database

Panaragan provides a thin database layer on top of JDBC with:connection pooling, safeprepared statements, and concise CRUD helpers. For reads, useQuery Builder(cached, convenient). For writes or custom SQL, use Database helpers or raw PreparedStatement.

Configuration recap (.env)

Database is configured at bootstrap and pooled. Single DB or multi-DB are supported:

# Single DB
db.url=jdbc:postgresql://127.0.0.1:5432/panaragan
db.username=postgres
db.password=postgres
db.max_pool_size=5
db.reconnect=5

# Multi DB (db1, db2, ...)
# db1.url=...
# db1.username=...
# db1.password=...
# db1.max_pool_size=5
# db1.reconnect=5
Notes:values are read once at startup. For multi-DB, index-based APIs (e.g. getPrepareStatement(int,...)) target the corresponding configured database by index.

API at a glance

  • Database.getPrepareStatement(String sql), getPrepareStatement(int index, String sql), getPrepareStatement(String sql, int returnKey)
  • Database.getMetaData(), getMetaData(int index)
  • Database.insert(String table, ParamsObject data)boolean
  • Database.insertGetInt(String table, ParamsObject data)intgenerated id
  • Database.insertGetString(String table, ParamsObject data)Stringgenerated id
  • Database.update(String table, ParamsObject data, String conditionOrColumn, Object... values)boolean
  • Database.delete(String table, String conditionOrColumn, Object... values)boolean
Automatic cache invalidation:after a successful insert/update/delete, Panaragan clears any cached QueryBuilder results related to the touched table.

Insert

// 1) Simple insert
ParamsObject user = new ParamsObject()
  .set("username", "johndoe")
  .set("email", "john@example.com")
  .set("status", 1);

boolean ok = Database.insert("users", user);

// 2) Insert and get generated key (int or string)
int newId       = Database.insertGetInt("users", user);
String newIdStr = Database.insertGetString("users", user);

Update

update accepts aconditionstring. You can use: (A) standardplaceholders(?), or (B) a smalloperator suffixform for IN/NOT IN without writing parentheses.

// A) Placeholders (recommended)
ParamsObject data = new ParamsObject().set("status", 1).set("email_verified_at", new Timestamp(System.currentTimeMillis()));
boolean updated = Database.update("users", data, "id = ?", 42);

// B) Operator suffix for IN/NOT IN (no parentheses in condition; values come from varargs)
//    "col IN"  ... expands to "col IN (?, ?, ...)"
//    "col NOT IN" expands similarly
ParamsObject disable = new ParamsObject().set("status", 0);
Database.update("users", disable, "id IN", 10, 11, 12);      // expands placeholders based on values
Database.update("users", disable, "role_id NOT IN", 3, 5, 7);

Delete

// A) Placeholders
Database.delete("token", "token = ?", token);

// B) Operator suffix for IN/NOT IN
Database.delete("users", "id IN", 101, 102, 103);

// C) No placeholders (literal condition — be careful and validate inputs)
Database.delete("token", "valid < CURRENT_TIMESTAMP");

Reads

PreferQuery Builderfor SELECTs (it returns PJsonArray/PJsonObject and caches results). Use low-level JDBC when you need full control.

A) Low-level JDBC with getPrepareStatement

try (PreparedStatement st = Database.getPrepareStatement(
       "SELECT id, username, email FROM users WHERE status = ? ORDER BY id DESC LIMIT ?")) {
  st.setInt(1, 1);
  st.setInt(2, 20);
  try (ResultSet rs = st.executeQuery()) {
    while (rs.next()) {
      int id        = rs.getInt("id");
      String name   = rs.getString("username");
      String email  = rs.getString("email");
      // ... use values
    }
  }
} // auto-close

B) Query Builder (recommended for SELECT)

PJsonObject one = new QueryBuilder()
  .select("id","username","email","status")
  .from("users")
  .where("email", "john@example.com")
  .row();

PJsonArray list = new QueryBuilder()
  .select("id","username","email")
  .from("users")
  .where("status = ?", 1)
  .orderBy("id", "DESC")
  .limit(20)
  .results();

Multi-database (by index)

If you configured multiple databases (db1.*, db2.*, …), you can prepare a statement for a specific DB index:

// Prepare on database index #1
try (PreparedStatement st = Database.getPrepareStatement(1, "SELECT now()")) {
  try (ResultSet rs = st.executeQuery()) { /* ... */ }
}

Database metadata

DatabaseMetaData md = Database.getMetaData();
try (ResultSet tables = md.getTables(null, null, "%", new String[]{"TABLE"})) {
  while (tables.next()) {
    String name = tables.getString("TABLE_NAME");
    // ...
  }
}

Practical recipes (from the sample project)

Activation & reset flows

// Lookup activation/reset token
PJsonObject tok = new QueryBuilder()
  .select("user_id")
  .from("token")
  .where("token", token)
  .where("type", 2) // 1 forgot password, 2 activation
  .where("valid >= CURRENT_TIMESTAMP")
  .row();

if (tok != null) {
  // Activate user
  ParamsObject upd = new ParamsObject()
    .set("email_verified_at", new Timestamp(System.currentTimeMillis()))
    .set("status", 1);
  if (Database.update("users", upd, "id = ?", tok.getInt("user_id"))) {
    Database.delete("token", "token = ?", token);
    // ... render success
  }
}

Safety & tips

  • Always bind parameterswith ? to avoid SQL injection for dynamic values.
  • Use the operator suffixonly when it matches IN/NOT IN semantics; values are taken from the varargs.
  • Literal conditions(no ?) are passed as-is—validate inputs yourself.
  • Writes invalidate cachesof related QueryBuilder SELECTs automatically.
  • Auto-commit: there is no explicit transaction API in this layer—use JDBC directly if you need custom transactions.

Copy-paste reference

// INSERT
ParamsObject row = new ParamsObject().set("k","v");
Database.insert("table", row);
int id = Database.insertGetInt("table", row);

// UPDATE
Database.update("table", new ParamsObject().set("k","v"), "id = ?", id);
Database.update("table", new ParamsObject().set("flag", 0), "id IN", 10, 11, 12);

// DELETE
Database.delete("table", "id = ?", id);
Database.delete("table", "id IN", 10, 11, 12);

// SELECT (raw)
try (PreparedStatement st = Database.getPrepareStatement("SELECT * FROM table WHERE k = ?")) {
  st.setString(1, "v");
  try (ResultSet rs = st.executeQuery()) { /* ... */ }
}

// SELECT (QueryBuilder)
PJsonArray rows = new QueryBuilder().select("*").from("table").results();
See also
ReadQuery Builderfor fluent SELECTs and built-in caching, andCachefor behavior on invalidation.
Datatable

Datatable buildsserver-side JSONfor jQuery DataTables from a single SELECT … FROM … query (or a QueryBuilder). It handles: pagination (start, length), ordering (order[0][column]), per-column/global search, custom cell rendering, virtual columns, and optional per-columnsum. Use Response.send(datatable) to return the JSON.

Reads request fromGET or POST using keys like search[value], columns[i][data], order[0][column], order[0][dir], start, length. JSON shape: { "recordsTotal", "recordsFiltered", "data", "sum?" }.

Minimal server endpoint

public final class UsersController {

  // GET/POST /users/datatable
  public static void datatable(Request req, Response res) {

    // Base SELECT (must include the columns you will expose/edit/order)
    Datatable dt = new Datatable(
      "SELECT id, username, email, status, created_at FROM users"
    );

    // Optional: add an auto-numbering column (first column)
    dt.addNumbering("no");

    // Render existing columns (HTML-safe via Helper.escHtml if you inject markup)
    dt.edit("status", (_, v, __) -> "1".equals(v)
      ? "<span class=\\"badge badge-success\\">Active</span>"
      : "<span class=\\"badge badge-danger\\">Inactive</span>"
    );

    // Add a new virtual column (e.g., action URLs)
    dt.add("edit_url",   (_, __, row) -> Url.to("users/edit/"+Helper.encryptString(row.get("id"))));
    dt.add("delete_url", (_, __, row) -> Url.to("users/delete/"+Helper.encryptString(row.get("id"))));

    // Optional: fixed server-side filters applied before DataTables search/order
    dt.where("status", 1);                         // becomes "status = ?"
    // dt.orWhere("username LIKE ?", "%john%");    // standard placeholders

    // Respond as application/json (Panaragan calls toJsonString(request) internally)
    res.send(dt); // terminal
  }
}

Construct from QueryBuilder

// Any QueryBuilder that yields a valid SELECT ... FROM ...
QueryBuilder qb = new QueryBuilder()
  .select("id","username","email","status","created_at")
  .from("users")
  .where("status = ?", 1);

Datatable dt = new Datatable(qb);
res.send(dt); // terminal

Custom search (column & global)

Override how search terms are applied. The callback receives a Filter you can chain with: where(...), orWhere(...), between(...), whereIn(...).

dt.onSearch("email", (filter, column, value) -> {
  if (value != null && !value.isBlank()) {
    filter.where(column + " LIKE ?", "%" + value.trim() + "%");
  }
});

dt.onGlobalSearch("username", (filter, column, value) -> {
  if (value != null && !value.isBlank()) {
    filter.where(column + " LIKE ?", "%" + value.trim() + "%");
  }
});

Filter helpers (mini-DSL)

You can add your own fixed filters before generating JSON. Placeholders ? are supported, and operator suffixes auto-expand: "col IN", "col NOT IN", ">", ">=", "<", "<=", "LIKE".

// Standard placeholders
dt.where("created_at >= ?", "2024-01-01 00:00:00");
dt.orWhere("username LIKE ?", "%doe%");

// IN / NOT IN expansion (varargs become (?, ?, ...))
dt.where("id IN", 10, 11, 12);
dt.orWhere("role_id NOT IN", 3, 5, 7);

// Range
dt.between("created_at", "2024-01-01 00:00:00", "2024-12-31 23:59:59");

Render & virtual columns

// Format an existing column
dt.edit("created_at", (_, v, __) -> {
  // keep it simple; DB returns a string/datetime — return any HTML-safe string
  return Helper.escHtml(v);
});

// Add a new column not present in SELECT (e.g., buttons)
dt.add("actions", (_, __, row) -> {
  String id = Helper.encryptString(row.get("id"));
  return "<div class=\\"actions\\">"
       +   "<a href=\\"" + Url.to("users/edit/"+id) + "\\" class=\\"btn btn-outline\\">Edit</a>"
       +   "<button type=\\"button\\" class=\\"btn btn-danger\\" data-id=\\"" + id + "\\">Delete</button>"
       + "</div>";
});

Per-column sum (optional)

Include a numeric summary for one or more columns. The resulting JSON adds "sum": { ... }.

dt.sum("sort", total -> "Total: " + total);     // label/value is your string
dt.sum("id",   total -> String.valueOf(total)); // raw total as string

Frontend (jQuery DataTables)

Basic initialization, matching the JSON keys you expose. Use POST or GET — server reads both.

<table id="users-table" class="display" style="width:100%"></table>
<script>
  const table = $('#users-table').DataTable({
    processing: true,
    serverSide: true,
    ajax: { url: '{{ url('users/datatable') }}', type: 'POST' },
    order: [[0,'desc']], // example
    columns: [
      { data: 'no',         name: 'no' },        // from addNumbering("no")
      { data: 'id',         name: 'id' },
      { data: 'username',   name: 'username' },
      { data: 'email',      name: 'email' },
      { data: 'status',     name: 'status' },    // HTML badge rendered on server
      { data: 'created_at', name: 'created_at' },
      { data: null,         name: 'actions', orderable:false, searchable:false,
        render: row => (
          '<div class="actions-group">'
            + '<a href="' + row.edit_url + '" class="btn btn-outline">Edit</a>'
            + '<button type="button" class="btn btn-danger js-del" data-url="' + row.delete_url + '">Delete</button>'
          + '</div>'
        )
      }
    ]
  });
</script>

Practical patterns

// 1) Role-based scoping (fixed filter)
if (!req.isAdmin()) {
  dt.where("organization_id", req.getUserOrganizationId());
}

// 2) Date range from request (when provided)
String start = req.getQuery("start_date", null);
String end   = req.getQuery("end_date",   null);
if (start != null && end != null) {
  dt.between("created_at", start + " 00:00:00", end + " 23:59:59");
}

// 3) Custom global search behavior for a specific column only
dt.onGlobalSearch("email", (f, col, val) -> {
  if (val != null && !val.isBlank()) f.where(col + " LIKE ?", "%" + val.trim() + "%");
});

Copy-paste reference

// Construct
Datatable dt = new Datatable("SELECT id, name, status FROM users");
// or: new Datatable(new QueryBuilder().select("id","name","status").from("users"));

// Numbering, render, virtual column, sum
dt.addNumbering("no");
dt.edit("status", (_, v, __) -> "1".equals(v) ? "<b>Active</b>" : "Inactive");
dt.add("edit_url",   (_, __, row) -> Url.to("users/edit/"+Helper.encryptString(row.get("id"))));
dt.sum("id", total -> String.valueOf(total));

// Filters
dt.where("status", 1);
dt.orWhere("name LIKE ?", "%doe%");
dt.where("id IN", 10, 11, 12);
dt.between("created_at", "2024-01-01 00:00:00", "2024-12-31 23:59:59");

// Custom search hooks
dt.onSearch("email", (f, c, v) -> { if(v!=null&&!v.isBlank()) f.where(c+" LIKE ?", "%"+v.trim()+"%"); });

// Respond
res.send(dt); // terminal

Troubleshooting

  • Empty table:ensure your base SELECT … FROM … is valid and accessible by the DB user.
  • Missing columns:any column referenced by edit(), add(), ordering, or the frontend columns must be present in the SELECT (or provided by add(...)).
  • Wrong order/search:DataTables sends order[0][column] and columns[i][data]. Make sure your frontend columns names match the JSON keys output on the server.
  • IN with empty list:the builder expands to IN ('') (no matches). Validate user input before applying an IN filter.
  • HTML output:escape user data with Helper.escHtml(...) in your edit(...) to avoid XSS when returning HTML fragments.
See also
Databasefor write ops,Query Builderfor fluent SELECTs, andRequest / Responsefor JSON responses.
Email

Panaragan ships a lightweight SMTP client that talks directly to SMTP servers (LOGIN auth) and can optionally upgrade with STARTTLS. It sendsHTMLemails and supportsattachments. Configuration is read once at bootstrap from .env and kept in memory. If SMTP is not configured, Email.send(...) throws an error.

Configuration (.env)

Use either a single SMTP blockormultiple indexed blocks. For multiple servers, the first is smtp1.* (index 0), the second is smtp2.* (index 1), and so on.

# Single SMTP
smtp.host=smtp.example.com
smtp.port=587
smtp.username=mailer@example.com        # authentication & return-path address
smtp.password=your-password
smtp.from=Panaragan                     # display name in "From: Panaragan <mailer@example.com>"
smtp.start_ssl=false                    # true => STARTTLS

# Multiple SMTP (smtp1.*, smtp2.*, ...)
# smtp1.host=...
# smtp1.port=587
# smtp1.username=...
# smtp1.password=...
# smtp1.from=Panaragan
# smtp1.start_ssl=false

# smtp2.host=...
# smtp2.port=465
# smtp2.username=...
# smtp2.password=...
# smtp2.from=No-Reply
# smtp2.start_ssl=true
Notes:smtp.from is thedisplay name; the authenticated sender address is smtp.username. Values are read at startup; restart to apply changes.

API (overloads)

  • Email.send(String to, String subject, String body)
  • Email.send(String to, String subject, String body, List<File> attachments)
  • Email.send(String fromDisplayName, String to, String subject, String body, List<File> attachments)
  • Email.send(int index, String to, String subject, String body)  (index 0 = smtp1, 1 = smtp2, ...)
  • Email.send(int index, String to, String subject, String body, List<File> attachments)
  • Email.send(int index, String fromDisplayName, String to, String subject, String body, List<File> attachments)
Behavior:body is sent as text/html; charset=UTF-8. If attachments is non-empty, the message becomes a multipart/mixed email; content type of each file is guessed by extension.

Send a simple HTML email

In the sample CMS, HTML bodies are rendered from a view and passed to Email.send.

// Controller snippet (activation)
public static void sendActivation(Request req, Response res) {
  String email = req.getPost("email", "");
  String token = Helper.randomString(64);

  // Prepare template data
  ParamsObject data = new ParamsObject()
    .set("app_name", Config.get("app.name", "Panaragan"))
    .set("activation_url", Url.to("activation/" + token))
    .set("user_email", email);

  // Render HTML from a view and send
  String html = Template.render("email.activation-account", data);
  Email.send(email, "Activation Account", html);

  res.redirect("login", "success",
    "Registration successful. Please check your email to activate your account.");
}

Email view (escaped example)

<!-- views/email/activation-account.html -->
<!doctype html>
<html>
  <body style="font-family:ui-sans-serif,system-ui">
    <h1 style="font-size:18px;margin:0 0 12px">Activate your account</h1>
    <p style="margin:0 0 8px">Hi, {{ user_email }}</p>
    <p style="margin:0 0 16px">Click the button below to activate your account.</p>
    <p>
      <a href="{{ activation_url }}"
         style="display:inline-block;background:#0ea5e9;color:#fff;padding:10px 14px;border-radius:8px;text-decoration:none">
        Activate Now
      </a>
    </p>
  </body>
</html>

Attachments

Pass a list of files. The client infers content types from file extensions and sends as multipart/mixed.

List<File> files = List.of(
  new File("storage/invoice-1234.pdf"),
  new File("public/logo.png")
);

String html = Template.render("email.invoice", new ParamsObject()
  .set("invoice_no", "INV-1234")
  .set("issued_at", Helper.formatDateNow("yyyy-MM-dd")));

Email.send("customer@example.com", "Your Invoice INV-1234", html, files);

Choose SMTP by index

When you configured multiple SMTP blocks, select which one to use by index (0 for smtp1.*, 1 for smtp2.*, …).

// Use smtp2.* (index = 1)
String html = Template.render("email.system-notice",
  new ParamsObject().set("message", "System will be under maintenance tonight."));

Email.send(1, "ops@example.com", "System Notice", html);

Custom “From” display name

Override the display name in the From: header. The authenticated address remains smtp.username.

String html = "<p>Please do not reply to this message.</p>";
Email.send("No-Reply", "user@example.com", "Password Reset", html, null);

Preview template without sending (optional)

Sometimes you want to preview the email HTML in the browser before sending—render the same view as a page.

// Preview route
public static void preview(Request req, Response res) {
  ParamsObject data = new ParamsObject().set("reset_url", Url.to("reset-password/EXAMPLE"));
  res.renderView("email.request-reset-password", data); // only renders HTML (no email)
}

Troubleshooting

  • “No configuration email found”:SMTP is not configured or the list is empty—fill smtp.* or smtp1.* in .env and restart.
  • Connection/auth errors:check host, port, username/password; set smtp.start_ssl=true when your server requires STARTTLS.
  • Attachments not received:ensure files exist and your process can read them; content type is inferred by extension.
  • Display name not applied:remember the “From” address uses smtp.username; the fromDisplayName only changes the name part.
See also
Templatefor rendering email bodies, andConfigfor SMTP keys in .env.
Job (Scheduler)

Panaragan provides a lightweight job scheduler built on top of Java’s ScheduledThreadPoolExecutor. Use the static Job.* helpers to run tasks at fixed intervals (milliseconds, seconds, minutes, hours), at specific hours of day, or once every day (midnight, server time).

Available methods

  • Job.everyMilliSeconds(Runnable command, int intervalInMs)
  • Job.everySecond(Runnable command), Job.everySecond(Runnable command, int intervalInSecond)
  • Job.everyMinute(Runnable command), Job.everyMinute(Runnable command, int intervalInMinute)
  • Job.everyHour(Runnable command), Job.everyHour(Runnable command, int intervalInHour)
  • Job.everyHoursOfDay(Runnable command, int... hoursOfDay) — e.g. 9, 17
  • Job.everyDay(Runnable command), Job.everyDay(Runnable command, int intervalInDay) (runs daily at midnight, server time)
Where to schedule:call these in yourCore.boot(...)after routes are registered, so jobs start when the app boots.

Examples

1) Heartbeat every 5 seconds

// In Core.boot(...)
Job.everySecond(() -> {
  System.out.println("[heartbeat] " + System.currentTimeMillis());
}, 5);

2) Clean expired tokens every minute

Example matches the CMS sample logic (activation/reset tokens).

// In Core.boot(...)
Job.everyMinute(() -> {
  try {
    // Remove expired tokens (valid < now)
    Database.delete("token", "valid < CURRENT_TIMESTAMP");
  } catch (Exception e) {
    // Always catch inside jobs to avoid suppressing future runs
    e.printStackTrace();
  }
});

3) Run tasks at specific hours: 09:00 and 17:00

// In Core.boot(...)
Job.everyHoursOfDay(() -> {
  try {
    // Example: snapshot a lightweight report to DB
    ParamsObject row = new ParamsObject()
      .set("created_at", new java.sql.Timestamp(System.currentTimeMillis()))
      .set("message", "twice-a-day snapshot");
    Database.insert("system_log", row);
  } catch (Exception e) {
    e.printStackTrace();
  }
}, 9, 17);

4) Daily task at midnight (server time)

// In Core.boot(...)
Job.everyDay(() -> {
  try {
    // Example: disable old sessions
    Database.delete("sessions", "expired < CURRENT_TIMESTAMP");
  } catch (Exception e) {
    e.printStackTrace();
  }
});

5) High-frequency sampling (every 200 ms)

// In Core.boot(...)
Job.everyMilliSeconds(() -> {
  // Keep it fast & non-blocking
  // For example, increment an in-memory counter
}, 200);

Scheduling in Core.boot(...)

public final class AppCore implements Core {
  public void boot(final Routers r) throws Exception {
    // ... register routes

    // Jobs start here
    Job.everySecond(() -> { System.out.println("tick"); }, 5);

    Job.everyMinute(() -> {
      try { Database.delete("token", "valid < CURRENT_TIMESTAMP"); }
      catch (Exception e) { e.printStackTrace(); }
    });

    Job.everyHoursOfDay(() -> {
      try {
        ParamsObject row = new ParamsObject()
          .set("created_at", new java.sql.Timestamp(System.currentTimeMillis()))
          .set("message", "9am/5pm summary");
        Database.insert("system_log", row);
      } catch (Exception e) { e.printStackTrace(); }
    }, 9, 17);

    Job.everyDay(() -> {
      try { Database.delete("sessions", "expired < CURRENT_TIMESTAMP"); }
      catch (Exception e) { e.printStackTrace(); }
    });
  }

  // ... accept(...), authorize(...), respond(...), error(...)
}

Best practices

  • Keep jobs short & non-blocking.If a job is heavy, offload I/O and avoid long CPU spikes.
  • Always handle exceptions.Wrap job bodies in try/catch so future executions continue.
  • Be idempotent.Write jobs so re-running doesn’t corrupt state (e.g., use WHERE conditions).
  • Use DB helpers safely.Prefer placeholders or the supported IN/NOT IN expansion style (seeDatabase).
  • Time zone.everyHoursOfDay and everyDay follow server time.

Copy-paste reference

// Seconds, minutes, hours
Job.everySecond(() -> { /* ... */ });                 // default: 1s
Job.everySecond(() -> { /* ... */ }, 5);              // every 5s
Job.everyMinute(() -> { /* ... */ });                 // every minute
Job.everyMinute(() -> { /* ... */ }, 10);             // every 10 minutes
Job.everyHour(() -> { /* ... */ });                   // every hour
Job.everyHour(() -> { /* ... */ }, 6);                // every 6 hours

// Milliseconds
Job.everyMilliSeconds(() -> { /* ... */ }, 200);      // every 200 ms

// Specific hours (server time)
Job.everyHoursOfDay(() -> { /* ... */ }, 9, 17);      // 09:00 and 17:00 daily

// Daily at midnight (server time)
Job.everyDay(() -> { /* ... */ });                    // 00:00 daily
See also
Databasefor write operations in jobs,Emailto notify users from a job, andCachefor hot-path memoization.
Language (i18n)

Panaragan provides simple, file-based internationalization using JSON dictionaries loaded at bootstrap. Each language file is a flat key–value map (no plural rules, no ICU), and is placed under resources/languages/ (packaged inside the JAR at build time).

Directory & file format

Put one JSON file per language code. The file name is the language code (e.g. en.json, id.json).

src/main/resources/languages/
├─ en.json
└─ id.json

Example en.json:

{
  "app.title": "Panaragan",
  "app.subtitle": "Build your app to enterprise class",
  "button.save": "Save",
  "button.cancel": "Cancel",
  "auth.login": "Sign in",
  "auth.logout": "Sign out"
}

Example id.json:

{
  "app.title": "Panaragan",
  "app.subtitle": "Bangun aplikasi kelas enterprise",
  "button.save": "Simpan",
  "button.cancel": "Batal",
  "auth.login": "Masuk",
  "auth.logout": "Keluar"
}

Initialization

Call Language.init() once during application startup (before you use translations). It scans and loads all JSON files under languages/ from the classpath.

public final class Main {
  public static void main(String[] args) throws Exception {
    // Load dictionaries from resources/languages/*.json
    Language.init();

    // Continue with server/bootstrap…
    Server.run(AppCore.class);
  }
}

Basic usage in controllers

Pick a language code (e.g. from query/cookie/session), get a Language instance, translate keys, and pass strings to the view.

public final class HomeController {
  public static void index(Request req, Response res) {
    // choose a language: query takes precedence, fallback to cookie, then "en"
    String langCode = req.getQuery("lang", req.getCookie("lang", "en"));

    // get the language dictionary (returns an instance for the code)
    Language lang = Language.get(langCode);

    // look up keys
    String title    = lang.translate("app.title");
    String subtitle = lang.translate("app.subtitle");

    ParamsObject data = new ParamsObject()
      .set("page_title", title)
      .set("subtitle",  subtitle)
      .set("lang",      langCode);

    res.renderView("frontend.home", data); // terminal
  }
}

Using translations in views

Render values that you’ve already translated in the controller. (Escaped so it won’t render inside these docs.)

<!-- resources/views/frontend/home.html -->
<div class="prose max-w-none">
  <h1>{{ page_title }}</h1>
  <p class="text-slate-600">{{ subtitle }}</p>
  <button class="btn">{{ button_save }}</button>
</div>

To populate button_save (and friends), just translate in the controller and pass them:

Language lang = Language.get(langCode);
ParamsObject data = new ParamsObject()
  .set("page_title", lang.translate("app.title"))
  .set("subtitle",   lang.translate("app.subtitle"))
  .set("button_save",   lang.translate("button.save"))
  .set("button_cancel", lang.translate("button.cancel"));
res.renderView("frontend.home", data);

Switching language (via cookie)

Provide a small route to set the language preference and redirect back. Use a persistent cookie if you want it to stick across sessions.

public final class LangController {
  public static void set(Request req, Response res) {
    String code = req.getParam("code", "en");
    // only accept available languages
    if (!Language.isAvailable(code)) code = "en";

    // persist for 1 year
    res.setCookie("lang=" + code + "; Path=/; Max-Age=31536000; SameSite=Lax");
    res.redirectBack(); // terminal
  }
}

Language switcher (view example)

Render links for the languages you have. Build the list in the controller using Language.list().

// Controller fragment
ParamsObject data = new ParamsObject()
  .set("codes", Language.list()); // e.g., ["en", "id"]
res.renderView("frontend.partials.lang-switch", data);
<!-- resources/views/frontend/partials/lang-switch.html (escaped) -->
<div class="flex items-center gap-2">
  @if(codes != null)
    @foreach(code in codes)
      <a class="px-2 py-1 rounded hover:bg-slate-100"
         href="/lang/{{ code }}">{{ code.toUpperCase() }}</a>
    @endforeach
  @endif
</div>

Inspecting availability

The loader exposes what’s been loaded so you can guard against invalid codes.

// returns a list like ["en", "id", ...]
System.out.println(Language.list());

// quick check
if (!Language.isAvailable("id")) {
  // fallback to "en", or handle it accordingly
}

Parameter placeholders (simple)

If you store placeholders in strings (e.g., "Hello, {name}!"), perform replacement in your controller before passing to the view.

// en.json: "greet": "Hello, {name}!"
String tmpl = Language.get(langCode).translate("greet");
String greet = tmpl.replace("{name}", req.getUser("name", "Guest"));
data.set("greet", greet);

Common patterns

  • Keep keys stable:use dotted namespaces like auth.*, button.*, menu.*.
  • Translate in controllers:pass ready-to-render strings to your views.
  • Validate user selection:Language.isAvailable(code) before saving to cookie/session.
  • Restart on changes:dictionaries are loaded at bootstrap; rebuild/restart to apply file updates.

Copy-paste reference

// Init once
Language.init();

// Get dictionary
Language lang = Language.get("en");

// Translate keys
String t = lang.translate("button.save");

// Availability & list
boolean ok = Language.isAvailable("id");
java.util.List<String> codes = Language.list();

Troubleshooting

  • Null/empty translations:ensure the key exists in the JSON file and that Language.init() ran before use.
  • Language not switching:check the persisted cookie or query param and guard with Language.isAvailable(code).
  • Changes not applied:JSON files are read at startup; rebuild/restart the app.
  • Unexpected characters:save JSON files as UTF-8.
See also
Templatefor rendering views,Cookiesfor persisting lang selection, andCoreif you want to set a default language per request in accept(...).
Query Builder

QueryBuilder is a fluent SQL builder that outputs parameterized SQL and executes it via Panaragan’s pooled Database connections. It supportsSELECT / JOIN / WHERE / GROUP BY / HAVING / ORDER BY / LIMIT-OFFSET, flexible conditions (includingIN,NOT IN,LIKE,IS,IS NOT), and built-inresult cachingwith automatic invalidation on writes.

Return types:results()PJsonArray, row()PJsonObject, count()int, sum()double. Force variants (*Force) bypass the cache.

Basic pattern

// SELECT id, username FROM users WHERE status = ? ORDER BY id DESC LIMIT 10 OFFSET 0
PJsonArray rows = new QueryBuilder()
  .select("id","username")
  .from("users")
  .where("status = ?", 1)
  .orderBy("id", "DESC")
  .limit(10, 0)
  .results();

for (int i = 0; i < rows.size(); i++) {
  PJsonObject r = rows.getObject(i);
  int    id   = r.getInt("id");
  String name = r.getString("username");
}

Single row

PJsonObject user = new QueryBuilder()
  .select("id","email","created_at")
  .from("users")
  .where("email = ?", "john@example.com")
  .row();

if (user != null) {
  int    id  = user.getInt("id");
  String mail= user.getString("email");
}

Flexible WHERE (mini-DSL)

where(String conditionOrColumn, Object... values) rewrites conditions based on: placeholders ?, trailing operators (=, !=, >, <, LIKE, NOT LIKE, IS, IS NOT, IN, NOT IN), and null-handling.

QueryBuilder qb = new QueryBuilder().from("users");

// 1) With '?' placeholders (classic)
qb.where("status = ?", 1)
  .where("created_at >= ?", "2025-01-01");

// 2) Trailing operator without '?' — builder adds placeholders for you
qb.where("role_id =", 3)                 // → "role_id = ?"
  .where("deleted_at IS", (Object)null)  // → "deleted_at IS NULL"
  .where("deleted_at IS NOT", (Object)null); // → "deleted_at IS NOT NULL"

// 3) IN / NOT IN (values taken from varargs)
qb.where("id IN", 10, 11, 12);           // → "id IN (?,?,?)"
qb.where("id NOT IN", 1, 2);

// 4) OR chaining
qb.where("status = ?", 1)
  .orWhere("status = ?", 2);             // last WHERE becomes "(status=? OR status=?)"

LIKE helpers (DB-aware casting)

like(column, value) and orLike(column, value) append a LIKE ? clause, casting non-text columns appropriately depending on the configured database type.

new QueryBuilder()
  .select("id","username")
  .from("users")
  .like("username", "%john%")     // → "... WHERE (username)::text LIKE ?" (dialect-aware)
  .results();

Group nested conditions

Use groupWhere("(status = ? OR role = ?)", 1, "admin") to push a parenthesized condition.

new QueryBuilder()
  .from("users")
  .groupWhere("(status = ? OR role = ?)", 1, "admin")
  .where("created_at >= ?", "2025-01-01")
  .results();

whereIn / whereNotIn (list or CSV)

Besides the mini-DSL above, there are dedicated helpers that accept a comma-separated string or a ListObject.

ListObject ids = new ListObject().add(5).add(6).add(7);

new QueryBuilder()
  .from("users")
  .whereIn("id", ids)             // or: whereIn("id", "5,6,7")
  .results();

new QueryBuilder()
  .from("users")
  .whereNotIn("email", "a@x.com,b@x.com")
  .results();

JOINs

new QueryBuilder()
  .select("u.id","u.username","p.full_name")
  .from("users u")
  .join("profiles p", "p.user_id = u.id")            // INNER JOIN
  .leftJoin("roles r", "r.id = u.role_id")           // LEFT JOIN
  .where("u.status = ?", 1)
  .orderBy("u.id", "DESC")
  .limit(20, 0)
  .results();

GROUP BY / HAVING

PJsonArray stats = new QueryBuilder()
  .select("status", "COUNT(id) AS total")
  .from("users")
  .groupBy("status")
  .having("COUNT(id) > ?", 10)
  .orderBy("total", "DESC")
  .results();

ORDER BY / LIMIT-OFFSET

orderBy(column, direction) normalizes direction to ASC/DESC. limit(limit, offset) emits dialect-specific pagination (e.g., OFFSET/FETCH on Oracle 12c+).

new QueryBuilder()
  .from("audit_log")
  .orderBy("created_at", "DESC")
  .limit(50, 100) // LIMIT 50 OFFSET 100 (or dialect equivalent)
  .results();

Counting & Summation

int totalActive = new QueryBuilder()
  .from("users")
  .where("status = ?", 1)
  .count();                           // cached

double sumAmount = new QueryBuilder()
  .select("amount")
  .from("orders")
  .where("paid = ?", 1)
  .sum("amount");                     // cached

// Force variants bypass cache:
int freshCount   = new QueryBuilder().from("users").countForce();
double freshSum  = new QueryBuilder().from("orders").sumForce("amount");

Numbered results

resultsWithNumber("no") adds a running number column named no.

PJsonArray rows = new QueryBuilder()
  .select("id","username")
  .from("users")
  .orderBy("id","ASC")
  .resultsWithNumber("no");

int firstNo = rows.getObject(0).getInt("no"); // 1

Inspect the generated SQL

For debugging, you can get the SQL and its bound parameters before execution.

QueryBuilder qb = new QueryBuilder()
  .select("id","email")
  .from("users")
  .where("email LIKE", "%@example.com");

String sql    = qb.getQuery();
String sqlRow = qb.getQueryRaw();         // raw form (no limit/offset rewrite for count/sum)
ListObject ps = qb.getParameters();       // bound parameters (in order)

Caching & invalidation (automatic)

  • Read caches:results(), row(), count(), and sum() cache by a key derived from SQL + parameters.
  • TTL:dynamically set to max(queryTime, 1000 ms).
  • Coalescing:concurrent identical queries are coalesced (only one hits the DB).
  • Invalidation:any Database.insert/update/delete on a table clears cached entries linked to that table.
  • Bypass:use *Force variants when you must skip cache.

Copy-paste reference

// Build basics
new QueryBuilder().select("*").from("t").results();
new QueryBuilder().from("t").where("id =", 10).row();
new QueryBuilder().from("t").orderBy("id","DESC").limit(20,0).results();

// WHERE mini-DSL
qb.where("id IN", 1,2,3);
qb.where("deleted_at IS", (Object)null);
qb.where("name LIKE", "%john%");
qb.orWhere("status =", 2);
qb.groupWhere("(status = ? OR role = ?)", 1, "admin");

// Lists
ListObject ids = new ListObject().add(1).add(2);
qb.whereIn("id", ids);
qb.whereNotIn("id", "3,4,5");

// Aggregates
int c = new QueryBuilder().from("t").count();
double s = new QueryBuilder().from("t").sum("amount");

// Joins
qb.select("u.id","p.full_name")
  .from("users u")
  .leftJoin("profiles p","p.user_id = u.id");
See also
Databasefor low-level CRUD and write operations, andDatatablefor server-side DataTables responses.
Request / Response

Request gives you a clean API to read the HTTP method, path, headers, cookies, sessions, route params, query/form inputs, JSON body, and uploaded files. Response lets you render views, send JSON/HTML/bytes/files, set headers/content type, and redirect (including back with old input).Important:terminal methodsrenderView, send, redirect, redirectBack, redirectBackWithInput, empty — immediately stop further execution.

Essentials at a glance

// Method, path, IP, UA, headers
String method   = request.getMethod();
String path     = request.getPath();
String ip       = request.getIP();
String agent    = request.getUserAgent();
String referer  = request.getHeader("Referer", "");
String traceId  = request.getHeader("X-Request-ID", "n/a");
String fullUrl  = request.getFullUrl(); // scheme + host + path + query

// Route params, query, form (typed overloads with defaults)
int    id       = request.getParam("id", 0);
String q        = request.getQuery("q", "");
int    page     = request.getQuery("page", 1);
String email    = request.getPost("email", "");
boolean agree   = request.getPost("agree", false);
long   stamp    = request.getPostOrQuery("ts", 0L);   // check POST first, then query

// Body & JSON
String       rawBody = request.getBody();
PJsonObject  jsonObj = request.getJsonObject(); // or null if not JSON
PJsonArray   jsonArr = request.getJsonArray();  // for array payloads

// Sessions & cookies
String userJson = request.getSession("user");
String theme    = request.getCookie("theme", "light");
int    visits   = request.getCookie("visits", 0);

// Respond
ParamsObject data = new ParamsObject().set("page_title","Dashboard");
response.renderView("frontend.dashboard", data); // terminal

// or JSON
response.send(new PJsonObject().set("ok", true)); // terminal

Reading inputs

All getters have typed variants with default values to avoid boilerplate casting/parsing.

// /orders/{id}?status=paid&page=2
r.get("/orders/{id}", Orders::show);

public final class Orders {
  public static void show(Request req, Response res) {
    int    id     = req.getParam("id", 0);
    String status = req.getQuery("status", "all");
    int    page   = req.getQuery("page", 1);

    PJsonObject debug = new PJsonObject()
      .set("id", id)
      .set("status", status)
      .set("page", page)
      .set("agent", req.getUserAgent());

    res.send(debug); // terminal JSON
  }
}

Forms & validation

// Display + submit
r.get("/contact", Contact::form);
r.post("/contact-submit", Contact::submit);

public final class Contact {
  public static void form(Request req, Response res) {
    ParamsObject data = new ParamsObject().set("page_title","Contact Us");
    res.renderView("frontend.contact", data);
  }
  public static void submit(Request req, Response res) {
    ParamsString rules = new ParamsString()
      .set("name",    "required|min_length[3]|max_length[100]")
      .set("email",   "required|email")
      .set("message", "required|min_length[10]");

    if (!req.validatePost(rules)) {
      res.redirectBackWithInput("error", "Please fix the highlighted fields"); // terminal
    }

    // ... send email / persist
    res.redirect("contact", "success", "Thanks! We'll get back to you soon."); // terminal
  }
}

In your view, render CSRF + flashes + old input (escaped here):

<form method="post" action="{{ url('contact-submit') }}" class="space-y-3">
  @csrf
  <input name="name"    value="{{ old('name') }}" />
  <input name="email"   value="{{ old('email') }}" />
  <textarea name="message">{{ old('message') }}</textarea>

  @if (session('error'))
    <div class="text-red-600 text-sm">{{ session('error') }}</div>
  @endif
  @if (session('success'))
    <div class="text-green-600 text-sm">{{ session('success') }}</div>
  @endif

  <button class="px-3 py-2 rounded bg-slate-900 text-white">Send</button>
</form>

File uploads

Read one or many files. Validate type/size, then store or reject.

// Single file
UploadFile avatar = request.getFile("avatar");
if (avatar != null && avatar.isExist()) {
  boolean ok = avatar.saveIfContentTypeMatch("image/png"); // enforce MIME
  if (!ok) { response.redirectBackWithInput("error", "Only PNG allowed"); } // terminal
}

// Multiple files
if (request.hasFiles()) {
  for (UploadFile f : request.getFiles()) {
    // e.g., f.saveIfContentTypeMatch("application/pdf");
  }
}

Cookies & session

// Read cookies (typed)
String theme   = request.getCookie("theme", "light");
boolean logged = request.getCookie("logged_in", false);
int visits     = request.getCookie("visits", 0);

// Set cookie (name=value; attributes)
response.setCookie("session_id=" + java.util.UUID.randomUUID() + "; Path=/; HttpOnly; Secure");

// Session (set on login, read later)
response.setSession("user", userJsonString);
String userJson = request.getSession("user");
response.destroySession(); // logout

Rendering views (HTML)

// Normal page
ParamsObject data = new ParamsObject()
  .set("page_title", "Home")
  .set("intro", "Build fast. Ship faster.");
response.renderView("frontend.home", data); // terminal

// Custom status page (e.g., 404)
response.renderView(404, "Not Found", "error.404",
  new ParamsObject().set("page_title","Not Found").set("path", request.getPath())); // terminal

JSON & Datatable responses

// JSON object/array
PJsonObject obj = new PJsonObject().set("ok", true).set("time", System.currentTimeMillis());
PJsonArray  arr = new PJsonArray().add(1).add(2).add(3);

response.send(obj);                         // Content-Type: application/json
response.send(201, "Created", obj);         // with status/message
response.send(arr);

// Datatable (server-side response)
Datatable dt = new Datatable("SELECT id, username, email, status FROM users");
dt.edit("status", (col, val, row) -> "1".equals(val) ? "Active" : "Inactive");
dt.addNumbering("no");
response.send(dt);                           // terminal JSON for DataTables

Bytes & file downloads

// Raw bytes
byte[] csv = "id,name\n1,John".getBytes(java.nio.charset.StandardCharsets.UTF_8);
response.send(csv);                                      // inline (octet-stream)
response.send(csv, "report.csv", "text/csv");            // suggest filename + type

// File handle
File pdf = new File("docs/guide.pdf");
response.send(pdf);                                      // best-effort inline
response.send(pdf, "Guide.pdf");                         // suggest filename
response.send(pdf, "Guide.pdf", "application/pdf");      // force type

Empty responses (status only)

response.empty();                       // flush with current status & headers
response.empty(204, "No Content");       // useful for OPTIONS, successful PATCH/PUT with no body

Redirects (including back & old input)

// Basic redirect (to a named/url helper inside your app)
response.redirect("dashboard");                                // terminal

// With flashes (ParamsString)
ParamsString flashes = new ParamsString().set("success","Saved!");
response.redirect("users", flashes);                           // terminal

// Single flash key/value overload
response.redirect("login", "error", "Invalid credentials");    // terminal

// Back (uses Referer)
response.redirectBack();                                       // terminal
response.redirectBack(new ParamsString().set("success","Done"));// terminal

// Back with input (also persists old input)
response.redirectBackWithInput("error","Please fix fields");   // terminal
response.redirectBackWithInput(new ParamsString().set("error","Please fix fields")); // terminal

In templates, access flashes via {{ session('success') }} / {{ session('error') }} and inputs via {{ old('field') }}.

Common recipes

1) Classic GET form + POST submit (with errors and old input)

r.get("/profile", Profile::form, "auth");
r.post("/profile", Profile::save, "auth");

public final class Profile {
  public static void form(Request req, Response res) {
    ParamsObject data = new ParamsObject().set("page_title","Edit Profile");
    res.renderView("profile.form", data);
  }
  public static void save(Request req, Response res) {
    ParamsString rules = new ParamsString()
      .set("name","required|min_length[3]")
      .set("email","required|email");
    if (!req.validatePost(rules)) {
      res.redirectBackWithInput("error","Please fix the fields"); // terminal
    }
    // ... update DB
    res.redirect("profile", "success", "Profile updated"); // terminal
  }
}
@extends('layouts.frontend')
@section('body')
  <form method="post" action="{{ url('profile') }}" class="space-y-3">
    @csrf
    <input name="name"  value="{{ old('name')  }}" />
    <input name="email" value="{{ old('email') }}" />

    @if (session('error'))
      <div class="text-red-600 text-sm">{{ session('error') }}</div>
    @endif
    @if (session('success'))
      <div class="text-green-600 text-sm">{{ session('success') }}</div>
    @endif

    <button class="px-3 py-2 rounded bg-slate-900 text-white">Save</button>
  </form>
@endsection

2) JSON CRUD (show, create, update)

// Show
r.get("/api/users/{id}", UsersApi::show);
public static void show(Request req, Response res) {
  int id = req.getParam("id", 0);
  // ... fetch from DB
  res.send(new PJsonObject().set("id", id).set("name","John Doe"));
}

// Create (201)
r.post("/api/users", UsersApi::create);
public static void create(Request req, Response res) {
  PJsonObject payload = req.getJsonObject();
  // ... insert & get newId
  res.send(201, "Created", new PJsonObject().set("id", 101).set("ok", true));
}

// Update (204)
r.put("/api/users/{id}", UsersApi::update);
public static void update(Request req, Response res) {
  int id = req.getParam("id", 0);
  PJsonObject payload = req.getJsonObject();
  // ... apply update
  res.empty(204, "No Content");
}

3) Upload then redirect with message

r.post("/profile/avatar", Avatar::upload, "auth");

public final class Avatar {
  public static void upload(Request req, Response res) {
    UploadFile f = req.getFile("avatar");
    if (f == null || !f.isExist() || !f.saveIfContentTypeMatch("image/png")) {
      res.redirectBackWithInput("error","PNG avatar required"); // terminal
    }
    res.redirect("profile", "success", "Avatar uploaded"); // terminal
  }
}

4) CSV export

r.get("/reports/users.csv", Reports::users);

public final class Reports {
  public static void users(Request req, Response res) {
    String csv = "id,name\n1,John\n2,Jane\n";
    res.send(csv.getBytes(java.nio.charset.StandardCharsets.UTF_8),
             "users.csv", "text/csv"); // terminal
  }
}

Troubleshooting

  • Headers missing in response:set thembeforecalling any terminal method; after send/renderView/redirect/empty, the response is finalized.
  • Old input doesn’t show:use redirectBackWithInput(...) on failure and in the view use {{ old('field') }}.
  • JSON is returned as HTML:set content type in Core.accept for /api/* or call response.setContentType("application/json") before send(...).
  • CSRF mismatch on POST:include @csrf in the form and ensure you post back to the same origin.
  • File rejected:confirm MIME checks and server/proxy body-size limits; adjust validation or file handling accordingly.
See also
Continue withValidation,Upload File, andRouter.
Router

The router maps HTTP requests to handler methods. In Panaragan, handlers use the functional interface Routers.Boot with the signature void accept(Request, Response), so you can pass method references like Home::index. Register routes inside Core.boot(Routers r).

HTTP verbs & utilities

Supported registration methods (from the source):

  • r.get(path, handler) / r.get(path, handler, authority)
  • r.post(path, handler) / r.post(path, handler, authority)
  • r.put(path, handler) / r.put(path, handler, authority)
  • r.delete(path, handler) / r.delete(path, handler, authority)
  • r.head(path, handler) / r.head(path, handler, authority)
  • r.options(path, handler) / r.options(path, handler, authority)
  • r.getOrPost(path, handler) / r.getOrPost(path, handler, authority) — registers both GET and POST.
  • r.all(path, handler) / r.all(path, handler, authority) — registers GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS for the same path.
  • r.path(path, handler) / r.path(path, handler, authority)PANARAGAN PATCH(method name path, handles HTTP PATCH).
// Inside Core.boot(Routers r)
r.get("/", Home::index);
r.post("/login", Auth::submit, "guest");
r.put("/profile", Profile::update, "auth");
r.delete("/users/{id}", UsersDelete::remove, "users_delete");
r.path("/profile/avatar", ProfileAvatar::patch, "auth"); // PATCH via r.path(...)
r.head("/files/{id}", Files::headInfo);
r.options("/api", Cors::preflight);

r.getOrPost("/search", Search::index); // register GET and POST
r.all("/webhook", Webhook::receive);   // register all verbs for the same handler

Dynamic path parameters

Use {name} to capture a path segment. Read it in your handler via request.getParam("name") or a typed overload with default:

// Route
r.get("/users/{id}", UsersShow::show, "users_view");

// Handler
public final class UsersShow {
  public static void show(Request req, Response res) {
    int id = req.getParam("id", 0); // typed int with default
    // ... load user, then render or return JSON
    ParamsObject data = new ParamsObject().set("page_title", "User #"+id);
    res.renderView("users.show", data); // terminal
  }
}

Tip: Request also provides getQuery(...), getPost(...), and getPostOrQuery(...) with typed overloads (String, int, boolean, long, float, double).

Grouping (prefix + authority inheritance)

Use r.group(prefix, group -> { ... }) to apply a common URL prefix. Add an authority string to enforce an authorization requirement for all children (unless a child route overrides its own authority).

// Example: /admin group with "auth" authority
r.group("/admin", "auth", g -> {
  g.get("/", Admin::dashboard);                   // inherits "auth"
  g.get("stats", Admin::stats);                   // inherits "auth"

  // Nested group: /admin/users
  g.group("users", "users_list", ug -> {
    ug.get("/", UsersIndex::index);               // requires "users_list"
    ug.post("store", UsersAdd::store, "users_add");     // overrides to "users_add"
    ug.get("edit/{id}", UsersEdit::form, "users_edit"); // overrides to "users_edit"
    ug.delete("{id}", UsersDelete::remove, "users_delete");
  });
});

Slashes are normalized for you. Children without an explicit authority inherit from the nearest group.

Special verbs: HEAD & OPTIONS

HEADshould return headers without a body;OPTIONSis commonly used for CORS preflight.

// HEAD: send headers only
r.head("/files/{id}", Files::headInfo);

public final class Files {
  public static void headInfo(Request req, Response res) {
    String id = req.getParam("id", "0");
    // set headers describing the resource
    res.addHeader("X-File-ID", id);
    res.addHeader("Content-Length", "0");
    res.empty(200, "OK"); // terminal, no body
  }
}
// OPTIONS: simple CORS preflight on /api
r.options("/api", Cors::preflight);

public final class Cors {
  public static void preflight(Request req, Response res) {
    res.addHeader("Access-Control-Allow-Origin",  "*");
    res.addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS,PATH");
    res.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.empty(204, "No Content"); // terminal
  }
}

PATCH (use r.path(...))

Panaragan registers HTTP PATCH via the method named path.

// PATCH example (profile partial update)
r.path("/profile", Profile::patch, "auth");

public final class Profile {
  public static void patch(Request req, Response res) {
    // read only provided fields; do partial update
    String name  = req.getPostOrQuery("name", null);
    String about = req.getPostOrQuery("about", null);
    // ... apply updates
    res.send(new PJsonObject().set("ok", true)); // terminal JSON
  }
}

Patterns & best practices

  • Prefer static pathswhere possible; they resolve fastest. Use {id} segments only where needed.
  • One responsibility per handler: read inputs, delegate to service, then respond (HTML/JSON/file).
  • Use getOrPost() for formsyou want to support via both GET (display) and POST (submit).
  • Use all() for webhooksor integrations where providers may vary the verb.
  • Authority stringsare free-form: decide meaning in Core.authorize() (e.g., "auth", "guest", "users_list").

Mini CRUD example

// boot()
r.group("/posts", "auth", g -> {
  g.get("/", PostsIndex::index);               // list
  g.get("create", PostsCreate::form, "posts_add");
  g.post("", PostsCreate::store, "posts_add"); // create
  g.get("{id}", PostsShow::show, "posts_view"); // show
  g.put("{id}", PostsUpdate::update, "posts_edit"); // full update
  g.path("{id}", PostsUpdate::patch, "posts_edit");  // partial update (PATCH)
  g.delete("{id}", PostsDelete::remove, "posts_delete");
});

// handler sample: PostsShow
public final class PostsShow {
  public static void show(Request req, Response res) {
    int id = req.getParam("id", 0);
    // Load and render
    ParamsObject data = new ParamsObject()
      .set("page_title", "Post #"+id)
      .set("post_id", id);
    res.renderView("posts.show", data); // terminal
  }
}

Troubleshooting (Router)

  • 404 Not Found:verify the path you registered matches exactly; for dynamic paths, ensure the placeholder names match your getParam(...) calls.
  • Authorize not enforced:set an authority on the route or group, and implement logic in Core.authorize(). A terminal response there stops the pipeline (no return needed after it).
  • Wrong handler called:check for overlapping routes under the same method; prefer specific (static) paths before dynamic ones.
  • CORS failing:add an explicit r.options(...) route and send the appropriate Access-Control-* headers.
See also
Next, readRequest / Responsefor parsing inputs and returning HTML/JSON/files.
Validation

Panaragan ships a request-aware validator. Use request.validatePost(rules[, labels[, messages]]) to validate form input (including multipart uploads). On failure it stores a structured error bag internally and returns false. Combine it with response.redirectBack(request.getPosts()) to flash field errors and old values back to the form. CSRF is checked first; when the token is invalid, validatePost(...) returns false immediately.

API (controller)

  • boolean ok = request.validatePost(ParamsString rules)
  • boolean ok = request.validatePost(ParamsString rules, ParamsString labels)
  • boolean ok = request.validatePost(ParamsString rules, ParamsString labels, ParamsParamsString messages)
  • On failure, call response.redirectBack(request.getPosts()) to flash errors + old values.
  • In views, use request.getErrorField(name) and request.getOld(name).

Rule syntax

Rules are specified per field as a |-separated string:

// Example rules map
ParamsString rules = new ParamsString()
  .put("email",    "required|email|max_length[120]")
  .put("password", "required|min_length[8]")
  .put("confirm",  "required|matches[password]")
  .put("age",      "numeric|greater_than[17]");

Built-in rules

  • required — must be present and non-empty (for multipart, passes if either text value exists or an uploaded file exists for that field).
  • min_length[N] / max_length[N] — string length.
  • matches[other_field] — value must equal another field’s value.
  • numeric — digits only (\\d+).
  • alpha — letters only ([a-zA-Z]+).
  • alpha_numeric — letters or digits ([a-zA-Z0-9]+).
  • email — basic email pattern.
  • greater_than[N] / less_than[N] — numeric comparison (parsed as int).
  • regex[pattern] — Java String.matches pattern. Remember to escape backslashes.
Notes:
  • For matches[password], the referenced field must be included in the same request.
  • Multipart forms: required passes if either the text value is non-emptyorthere is an uploaded file for that field.
  • When validation fails, Panaragan compiles per-field messages into a string (multiple messages are joined with line breaks) and flashes them to the next view.

1) Minimal example (POST form)

public final class AuthController {
  public static void register(Request req, Response res) {
    // 1) Define rules
    ParamsString rules = new ParamsString()
      .put("email",    "required|email|max_length[120]")
      .put("password", "required|min_length[8]")
      .put("confirm",  "required|matches[password]");

    // 2) Validate (CSRF is checked inside)
    if (!req.validatePost(rules)) {
      // 3) Flash old input + errors and go back
      res.redirectBack(req.getPosts()); // terminal
    }

    // 4) Use sanitized values (Strings)
    String email = req.getPost("email", "");
    String pass  = req.getPost("password", "");

    // ... create user, etc.
    res.redirect("login", "success", "Registration successful, please sign in.");
  }
}

Form view (show old values & errors)

<!-- escaped example view -->
<form method="post" action="/register" class="space-y-4">
  @csrf

  <div>
    <label class="block mb-1 font-medium">Email</label>
    <input name="email" type="email" class="input" value="{{ old('email') }}">
    @if(error('email'))<div class="text-red-600 text-sm">{{ error('email') }}</div>@endif
  </div>

  <div>
    <label class="block mb-1 font-medium">Password</label>
    <input name="password" type="password" class="input">
    @if(error('password'))<div class="text-red-600 text-sm">{{ error('password') }}</div>@endif
  </div>

  <div>
    <label class="block mb-1 font-medium">Confirm Password</label>
    <input name="confirm" type="password" class="input">
    @if(error('confirm'))<div class="text-red-600 text-sm">{{ error('confirm') }}</div>@endif
  </div>

  <button class="btn btn-primary">Create account</button>
</form>

2) Labels & custom messages

Supply human-friendly labels and per-rule custom messages per field. Message placeholders: {field}, {min_length}, {max_length}, {greater_than}, {less_than}, {matches}.

// Labels
ParamsString labels = new ParamsString()
  .put("email",    "Email address")
  .put("password", "Password")
  .put("confirm",  "Password confirmation");

// Messages (field -> (rule -> message))
ParamsParamsString messages = new ParamsParamsString()
  .put("email", new ParamsString()
    .put("required",    "{field} is required")
    .put("email",       "Please enter a valid {field}")
    .put("max_length",  "{field} must be under {max_length} characters"))
  .put("password", new ParamsString()
    .put("required",    "{field} is required")
    .put("min_length",  "{field} must be at least {min_length} characters"))
  .put("confirm", new ParamsString()
    .put("matches",     "{field} must match {matches}"));

if (!req.validatePost(rules, labels, messages)) {
  res.redirectBack(req.getPosts()); // terminal
  return;
}

3) File uploads (multipart)

For multipart forms, required on a field passes if the user either typed a non-empty valueoractually selected a file for that field. This is handy for optional text + file inputs. Length, pattern, and matching rules apply to the text value (not the file bytes).

// Controller
public static void saveAvatar(Request req, Response res) {
  ParamsString rules = new ParamsString()
    .put("avatar", "required"); // require a chosen file

  if (!req.validatePost(rules)) {
    res.redirectBack(req.getPosts()); // terminal
    return;
  }

  // Access uploaded file
  UploadFile file = req.getFile("avatar");
  // ... move/store as needed

  res.redirect("profile", "success", "Avatar updated.");
}
<!-- View -->
<form method="post" action="/profile/avatar" enctype="multipart/form-data">
  <input type="hidden" name="{{ request.getCSRFTokenName() }}"
         value="{{ request.getCSRFTokenValue() }}">
  <input type="file" name="avatar">
  <div class="text-red-600 text-sm">{{ request.getErrorField('avatar') }}</div>
  <button class="btn">Save</button>
</form>

4) Advanced: validate outside controllers

For service-level validation (without using request.validatePost(...)), call the static validator. It returns a ParamsListString (map: field → list of messages). An empty result means “no errors”.

// Build a data bag (like request posts)
ParamsString inputs = new ParamsString()
  .put("email", "bad@")
  .put("age",   "x");

// Define rules
ParamsString rules = new ParamsString()
  .put("email", "required|email")
  .put("age",   "numeric");

// Optional: labels and messages
ParamsString labels = new ParamsString().put("age", "Age");
ParamsParamsString msgs = new ParamsParamsString()
  .put("email", new ParamsString().put("email", "Invalid {field} format"));

// Validate
ParamsListString errs = Validation.input(inputs, rules, labels, msgs);
if (!errs.isEmpty()) {
  // handle error bag ...
}

Troubleshooting

  • Nothing happens on submit:ensure you include the CSRF hidden input with request.getCSRFTokenName() / request.getCSRFTokenValue().
  • Errors not visible:after failure, call response.redirectBack(request.getPosts()) and in the view print {{ request.getErrorField('field') }}.
  • Old values not restored:use response.redirectBack(request.getPosts()) and in the form set value="{{ request.getOld('field') }}".
  • regex[...] not matching:remember Java patterns need escaping (e.g., \\d+), and the delimiter is [ ... ].
See also
CSRFfor token helpers,Request / Responsefor redirects & flashing, andUpload Filefor handling uploaded files.
Session

Panaragan provides a cookie-based session with pluggable backends and a simple static API. Use Session.put(request, key, value) to store values and Session.get(request, key) to read them. The session id is kept in a cookie (name from .env) and the store choice + expiry are configured at bootstrap.

Configuration (.env) recap

# Backend & lifetime
session.mode=MEMORY            # MEMORY | STORAGE | DATABASE | CUSTOM
session.expired=3600           # seconds (TTL)

# Cookie
session.key=_secure_SESSION_ID # cookie name
session.path=/
session.domain=
session.http_only=true
session.secure=false
session.same_site=Lax          # Lax | Strict | None

# Store path/class hint (used by STORAGE / DATABASE / CUSTOM)
session.storage=storage/sessions
  • MEMORY: in-process map (lost on restart).
  • STORAGE: file-based under session.storage (ensure readable/writable).
  • DATABASE: DB-backed (uses your configured database; see your schema/initializer).
  • CUSTOM: provide your own implementation; path/class hint via session.storage.
Values are loaded from .env at startup and kept in memory; restart the app to apply changes. Set session.secure=true and an appropriate SameSite policy in production.

API (controller)

  • Session.put(Request req, String key, Object value)
  • Object Session.get(Request req, String key)

Login flow (store user id)

public final class AuthController {
  public static void doLogin(Request req, Response res) {
    String email = req.getPost("email", "");
    String pass  = req.getPost("password", "");

    // 1) Verify credentials (example)
    PJsonObject user = new QueryBuilder()
      .select("id","email","password_hash","status")
      .from("users")
      .where("email = ?", email)
      .row();

    if (user == null || !Password.check(pass, user.getString("password_hash"))) {
      res.redirect("login", "error", "Invalid credentials");
      return; // terminal
    }

    // 2) Put auth keys in session
    Session.put(req, "auth_user_id", user.getInt("id"));
    Session.put(req, "auth_email",   user.getString("email"));

    // 3) Proceed
    res.redirect("dashboard", "success", "Welcome!");
  }
}

Checking auth in a protected action

public final class DashboardController {
  public static void index(Request req, Response res) {
    Object uid = Session.get(req, "auth_user_id");
    if (uid == null) {
      res.redirect("login", "error", "Please sign in first");
      return; // terminal
    }
    res.renderView("frontend.dashboard");
  }
}

Logout (clear and redirect)

Remove the keys you set for authentication, then redirect. (The exact “clear” strategy depends on your store; the simplest is to overwrite the keys you rely on.)

public final class AuthController {
  public static void logout(Request req, Response res) {
    // clear what you used to check auth
    Session.put(req, "auth_user_id", null);
    Session.put(req, "auth_email", null);

    res.redirect("login", "success", "Signed out");
  }
}

Flash messages (via redirect helpers)

Redirect helpers such as response.redirect(..., "success", "Message") and response.redirectBack(request.getPosts()) use the session to carry a single-use message (and old form values). They are consumed on the next request automatically.

Per-request defaults in Core.accept(...)

If you need to attach the current user to the request for downstream controllers, read the session inside your Core.accept and set a payload.

public void accept(final Request req, final Response res) {
  Object uid = Session.get(req, "auth_user_id");
  if (uid != null) {
    // attach a lightweight payload — use what your app needs
    req.setPayload(new ParamsObject().set("user_id", uid));
  }
}

Store-specific notes

  • MEMORY: fastest; data is lost on restart. Good for development or stateless nodes with sticky sessions.
  • STORAGE: ensure session.storage exists and is writable by the process user.
  • DATABASE: you’ll need a table compatible with the session format used in your project; expired rows should be cleaned periodically.
  • TTL: entries older than session.expired seconds are considered invalid by readers; implement a cleanup job if you want to trim persistent stores.

Cleanup job example

// Run daily at midnight to purge DB sessions (if you store them in a table)
Job.everyDay(() -> {
  try { Database.delete("sessions", "expired < CURRENT_TIMESTAMP"); }
  catch (Exception e) { e.printStackTrace(); }
});

Copy-paste reference

// Write
Session.put(req, "auth_user_id", 123);

// Read
Object uid = Session.get(req, "auth_user_id");

// Clear a value your app relies on
Session.put(req, "auth_user_id", null);

// Guarded route
if (Session.get(req, "auth_user_id") == null) {
  res.redirect("login", "error", "Please sign in first");
  return; // terminal
}
See also
Cookiesfor low-level cookie controls,Coreto attach per-request payloads, andRequest / Responsefor redirect helpers & flashing.
Template

Panaragan uses a Blade-like HTML templating syntax with layouts and sections. Views live under src/main/resources/views. Render a view to the HTTP response with Response.renderView(viewName[, data]), or render into a string (e.g., for email) with Template.render(viewName, data).

View location & naming

Dotted names map to subfolders. For example:

src/main/resources/views/
├─ layouts/
│  └─ frontend.html
├─ frontend/
│  ├─ home.html
│  └─ dashboard.html
└─ email/
   └─ activation-account.html

"frontend.home"views/frontend/home.html, "layouts.frontend"views/layouts/frontend.html, etc.

Layout & sections

Define a base layout with @yield placeholders. Child views call @extends and fill @section blocks. (Escaped below so it won’t render inside these docs.)

Base layout (with Tailwind CDN)

<!-- views/layouts/frontend.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>{{ config('app.name') }} - {{ page_title }}</title>
  <meta name="description" content="{{ page_description }}">

  <!-- Tailwind CDN to avoid missing local assets -->
  <script src="https://cdn.tailwindcss.com"></script>

  <!-- Optional: highlight.js for code samples in your site -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
  <script>window.addEventListener('DOMContentLoaded', () => hljs.highlightAll());</script>

  @yield('style')
</head>
<body class="antialiased text-slate-900 bg-slate-50">
  <header class="sticky top-0 z-50 bg-white/70 backdrop-blur-sm border-b border-slate-200">
    @yield('menus')
  </header>

  @yield('body')

  <footer class="bg-slate-800 text-slate-300 mt-16">
    <div class="max-w-7xl mx-auto px-6 py-8 flex justify-between">
      <div class="font-semibold text-white">Panaragan</div>
      <div class="text-sm text-slate-400">&copy; <span id="year"></span> Panaragan</div>
    </div>
  </footer>

  @yield('script')
</body>
<script>document.getElementById('year').textContent = new Date().getFullYear();</script>
</html>

Child view

<!-- views/frontend/home.html -->
@extends('layouts.frontend')

@section('menus')
  <div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
    <a class="flex items-center gap-3" href="/">
      <img src="/logo.png" alt="Panaragan" class="h-8 w-8">
      <div class="font-semibold">Panaragan</div>
    </a>
    <nav class="hidden md:flex gap-3 text-sm text-slate-600">
      <a href="/docs" class="px-3 py-2 rounded hover:bg-slate-100">Docs</a>
    </nav>
  </div>
@endsection

@section('body')
  <main class="max-w-7xl mx-auto px-6 py-10">
    <h1 class="text-2xl font-bold">{{ page_title }}</h1>
    <p class="text-slate-600 mt-2">{{ subtitle }}</p>

    <!-- Example conditional & loop -->
    @if(features != null)
      <ul class="list-disc pl-6 mt-4">
        @foreach(f in features)
          <li>{{ f }}</li>
        @endforeach
      </ul>
    @endif
  </main>
@endsection

Rendering in a controller

Pass a ParamsObject with the variables your view expects.

public final class HomeController {
  public static void index(Request req, Response res) {
    ParamsObject data = new ParamsObject()
      .set("page_title",       "Welcome")
      .set("page_description", "Panaragan starter")
      .set("subtitle",         "Build your app to enterprise class")
      .set("features",         new ListObject().add("Routing").add("Template").add("Database"));

    res.renderView("frontend.home", data); // terminal
  }
}

Template helpers inside views

A few helpers are available directly in views (as used throughout the sample):

  • {{ url('path-or-route') }} — build an absolute URL to a path (e.g., /css/app.css).
  • {{ config('app.name') }} — read a config value.
  • @csrf — inject a hidden input with the CSRF token (use inside forms).

Forms + CSRF

<form action="/profile/update" method="post" class="space-y-4">
  @csrf
  <input type="text" name="full_name" class="input" placeholder="Your name">
  <button class="btn">Save</button>
</form>

Rendering to a string (emails, etc.)

Use Template.render(view, data) when you need the HTML string (e.g., email body) instead of sending an HTTP response.

ParamsObject mailData = new ParamsObject()
  .set("user_email",     "user@example.com")
  .set("activation_url", Url.to("activation/ABC123"));

String html = Template.render("email.activation-account", mailData);
// pass to Email.send(...)

Conditionals & loops (in views)

The syntax mirrors the sample project:

<!-- escaped examples -->
@if(user != null)
  <p>Hello, {{ user.name }}</p>
@endif

@if(items != null)
  <ul>
    @foreach(item in items)
      <li>{{ item.title }}</li>
    @endforeach
  </ul>
@endif

SEO/meta example (as used by the sample layout)

<!-- in layouts/frontend.html (escaped) -->
<title>{{ config('app.name') }} - {{ page_title }}</title>
<meta name="description" content="{{ page_description }}">
<meta property="og:title" content="{{ config('app.name') }} - {{ page_title }}">
<meta property="og:description" content="{{ page_description }}">
<link rel="canonical" href="{{ page_url }}">

Troubleshooting

  • Blank page:ensure the child view calls @extends('layouts.frontend') and defines sections used by the layout (e.g., menus, body).
  • Undefined variables:pass them via ParamsObject when calling renderView/Template.render (e.g., page_title, page_description).
  • Missing CSS/JS:use the Tailwind CDN snippet above or ensure your asset files exist if you reference {{ url('css/frontend.css') }}.
  • Form submit rejected:include @csrf inside your form and POST to the route that validates it.

Copy-paste reference

// Controller → HTTP response
res.renderView("frontend.home", new ParamsObject().set("page_title","Home"));

// Controller → HTML string
String html = Template.render("email.activation-account",
  new ParamsObject().set("activation_url", Url.to("activation/TOKEN")));
<!-- Child view (escaped) -->
@extends('layouts.frontend')
@section('body')
  <h1>{{ page_title }}</h1>
@endsection
See also
CSRFfor secure forms,Emailfor rendering email bodies, andRequest / Responsefor response helpers.
Upload File

Panaragan mem-parsingmultipart/form-datadan menaruh file di Request. Ambil satu file dengan request.getFile(name) atau iterasi semua file lewat request.getFiles(). Gunakan objek UploadFile untuk menyimpan ke folder upload yang dikonfigurasi oleh app.path_upload.

Konfigurasi upload di .env

# Lokasi folder upload & URL slug untuk mengaksesnya
app.path_upload=storage/uploads
app.slug_upload=/uploads

# Batas ukuran request (MB)
request.max_size_on_mb=20
  • app.path_uploadharus folder yang valid dan writable.
  • app.slug_upload(opsional): jika di-set, URL yang diawali slug ini akan dipetakan otomatis ke app.path_upload (contoh: /uploads/avatar/john.png).
  • request.max_size_on_mb: batas maksimum ukuran request (default 20MB). Jika terlewati, request akan ditolak.

Form HTML

<form method="post" action="/profile/avatar" enctype="multipart/form-data" class="space-y-4">
  @csrf
  <input type="file" name="avatar" accept="image/png,image/jpeg">
  <button class="btn">Upload</button>
</form>

API ringkas

// Ambil satu file
UploadFile file = req.getFile("avatar");

// Cek ada/tidak
boolean exists = (file != null && file.isExist());

// Nama file yang diunggah (original)
String original = file.getUploadFilename();

// Simpan ke folder upload (relatif ke app.path_upload)
boolean ok1 = file.save("avatar/john.png");

// Simpan & cek content-type (single)
boolean ok2 = file.saveIfContentTypeMatch("avatar/john.png", "image/png");

// Simpan & cek content-type (multiple)
boolean ok3 = file.saveIfContentTypeMatch("avatar/john.jpg", new String[]{"image/jpeg","image/jpg"});

// Error terakhir (jika save gagal)
String err = file.getError();

// Iterasi semua file (map: field -> UploadFile)
for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
  String field = e.getKey();
  UploadFile f = e.getValue();
  // ...
}
Catatan:UploadFile.save(...) selalu menulis relatif ke app.path_upload. Method saveIfContentTypeMatch(...) akan memeriksa MIME type (menggunakan deteksi sederhana) dan menghapus file jika tidak sesuai, lalu mengisi getError(). Framework juga mencegah path traversal — hanya path di dalam app.path_upload yang diizinkan.

Contoh: upload avatar (validasi + simpan)

public final class ProfileController {
  public static void uploadAvatar(Request req, Response res) {
    // Validasi sederhana (contoh): pastikan field 'avatar' dikirim
    ParamsString rules = new ParamsString().put("avatar", "required");
    if (!req.validatePost(rules)) {
      res.redirectBack(req.getPosts()); // terminal
      return;
    }

    UploadFile file = req.getFile("avatar");
    if (file == null || !file.isExist()) {
      res.redirectBack(req.getPosts(), "error", "Please choose a file.");
      return; // terminal
    }

    // Buat nama target (jaga ekstensi dari nama asli bila perlu)
    String original = file.getUploadFilename();               // mis. "photo.png"
    String target   = "avatar/" + req.getUser("id", "user") + "-" + System.currentTimeMillis() +
                      (original.contains(".") ? original.substring(original.lastIndexOf('.')) : "");

    // Batasi hanya PNG/JPEG
    boolean ok = file.saveIfContentTypeMatch(target, new String[]{"image/png","image/jpeg"});
    if (!ok) {
      res.redirectBack(req.getPosts(), "error", file.getError()); // "Content type is not allowed", dll.
      return; // terminal
    }

    // Sukses — tampilkan URL publik jika slug di-setup
    // URL: app.slug_upload + target  (contoh: /uploads/avatar/123-...png)
    String publicUrl = (com.panaragan.Server.UPLOAD_SLUG.isBlank() ? "" : com.panaragan.Server.UPLOAD_SLUG) + target;
    res.redirect("profile", "success", "Avatar uploaded: " + publicUrl);
  }
}

Contoh: iterasi beberapa file

Jika kamu memiliki beberapa field file (mis. doc1, doc2, …), iterasi semua entri dan simpan sesuai kebutuhan.

<form method="post" action="/docs/upload" enctype="multipart/form-data">
  @csrf
  <input type="file" name="doc1">
  <input type="file" name="doc2">
  <button class="btn">Upload</button>
</form>
public static void uploadDocs(Request req, Response res) {
  java.util.List<String> saved = new java.util.ArrayList<>();

  for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
    String field = e.getKey();       // "doc1", "doc2", ...
    UploadFile f = e.getValue();
    if (f != null && f.isExist()) {
      String name = f.getUploadFilename();
      String target = "docs/" + field + "-" + System.currentTimeMillis() +
                      (name.contains(".") ? name.substring(name.lastIndexOf('.')) : "");
      if (f.save(target)) {
        saved.add(target);
      }
    }
  }

  if (saved.isEmpty()) {
    res.redirectBack(req.getPosts(), "error", "No files uploaded.");
    return; // terminal
  }
  res.redirect("docs", "success", "Uploaded: " + String.join(", ", saved));
}

Mengakses file yang sudah diupload

Jika app.slug_upload diset (mis. /uploads), maka /uploads/<path-yang-kamu-simpan> akan dilayani langsung dari folder app.path_upload.

app.path_upload = storage/uploads
app.slug_upload = /uploads

// Simpan: file.save("avatar/john.png")
// Akses:  GET /uploads/avatar/john.png   →  storage/uploads/avatar/john.png

Validasi ukuran & tipe

  • Ukuran requestdibatasi oleh request.max_size_on_mb. Jika terlampaui, request ditolak.
  • Tipe kontengunakan saveIfContentTypeMatch(...) dengan satu atau banyak MIME types.

Troubleshooting

  • Gagal simpan:cek file.getError(). Pastikan app.path_upload valid & writable.
  • 404 saat akses file:set app.slug_upload & pastikan path target saat save(...) cocok dengan URL.
  • “Request too large”:naikkan request.max_size_on_mb sesuai kebutuhan.
  • Jenis file ditolak:cocokkan daftar MIME type pada saveIfContentTypeMatch.

Copy-paste reference

// Single file
UploadFile f = req.getFile("avatar");
if (f != null && f.isExist()) {
  f.saveIfContentTypeMatch("avatar/u-1.png", "image/png");
}

// Multi-field
for (java.util.Map.Entry<String, UploadFile> e : req.getFiles()) {
  UploadFile uf = e.getValue();
  if (uf != null && uf.isExist()) { uf.save("misc/" + e.getKey() + ".bin"); }
}
See also
Validationuntuk memvalidasi field file,Request / Responseuntuk pengiriman file kembali ke klien, danConfiguntuk detail .env.
URL

Panaragan provides simple helpers to generate links consistently for controllers and views. In controllers/services, use Url.to(...). In views, use the template helper {{ url('...') }}. These are used throughout the CMS example (e.g., action links for DataTables, email activation links, asset links).

API

  • String Url.to(String path) — build a URL string to a path.
  • {{ url('path') }} (view helper) — echo a URL inside templates.
Notes:
  • Pass apathlike "login", "users/edit/123", or "css/frontend.css".
  • When composing URLs with user data, sanitize/escape in views as needed.
  • For uploaded files, combine your saved path with the upload slug (seeUpload File).

Use in controllers/services

// 1) Build an activation link for email
String token = Helper.randomString(64);
String activationUrl = Url.to("activation/" + token);

// 2) Action links for tables / JSON responses
String editUrl   = Url.to("users/edit/"   + Helper.encryptString(userId));
String deleteUrl = Url.to("users/delete/" + Helper.encryptString(userId));

// 3) Pass URLs to views or JSON
ParamsObject data = new ParamsObject()
  .set("activation_url", activationUrl)
  .set("profile_url",    Url.to("profile"));
res.renderView("frontend.home", data); // terminal

Use in DataTables (server-side)

// Add virtual columns with URLs
dt.add("edit_url",   (_, __, row) -> Url.to("users/edit/"   + Helper.encryptString(row.get("id"))));
dt.add("delete_url", (_, __, row) -> Url.to("users/delete/" + Helper.encryptString(row.get("id"))));

Use in views (templates)

<!-- escaped examples in a view -->
<a href="{{ url('login') }}" class="btn">Sign in</a>

<link rel="stylesheet" href="{{ url('css/frontend.css') }}">
<script src="{{ url('js/app.js') }}"></script>

<a href="{{ profile_url }}" class="btn-link">My profile</a>

With anchors and basic query strings

Build simple query strings/anchors by concatenation (keep it straightforward and explicit).

// Controller: search URL
String q = "john"; // ensure it's safe for your usage
String listUrl = Url.to("users?search=" + q + "&page=1") + "#result";
res.redirect(listUrl); // terminal
<!-- View: link to a section -->
<a href="{{ url('docs#install') }}">Go to Install</a>

Uploaded file URLs

After saving a file to app.path_upload (e.g., "avatar/john.png"), expose it with your upload slug.

// Combine saved path with the configured slug
String savedPath = "avatar/john.png";
String publicUrl = (com.panaragan.Server.UPLOAD_SLUG.isBlank() ? "" : com.panaragan.Server.UPLOAD_SLUG) + savedPath;
// e.g., "/uploads/avatar/john.png"
res.redirect("profile", "success", "Avatar uploaded: " + publicUrl);
<!-- View -->
<img src="{{ public_url }}" alt="Avatar" class="h-12 w-12 rounded-full">

Common patterns

  • Email links:create the link in the controller with Url.to(...), inject into the email view, send with Email.send(...).
  • JSON APIs:include prebuilt URLs (edit/delete/show) so the frontend doesn’t guess paths.
  • Assets:use {{ url('css/...') }} and {{ url('js/...') }} in the layout.

Troubleshooting

  • Broken links in emails:ensure you build links via Url.to(...) in the controller and pass them to the email view.
  • 404 for uploaded files:verify the prefix matches your configured upload slug and the saved path (seeUpload File).
  • Static assets not loading:check your asset files exist under public/ or use the CDN version where appropriate (e.g., Tailwind CDN).

Copy-paste reference

// Controller
String link = Url.to("users/edit/" + Helper.encryptString(id));
ParamsObject data = new ParamsObject().set("edit_url", link);
res.renderView("frontend.user-detail", data);
<!-- View -->
<a href="{{ edit_url }}" class="btn btn-outline">Edit</a>
See also
Routerfor defining endpoints,Templatefor view helper usage, andUpload Filefor public file URLs.

Build & Deploy

Prerequisites

  • Java Runtime (Temurin recommended).
  • A supported database (MySQL, PostgreSQL, SQL Server, Oracle) and the JDBC driver on classpath.
  • Reverse proxy (recommended): Nginx/Traefik/Caddy with TLS at the edge.
# .env (examples)
app.env=production
app.port=8080
app.base_url=https://yourdomain.tld

# Database (pick one; keep only what you use)
db.driver=com.mysql.cj.jdbc.Driver
db.url=jdbc:mysql://127.0.0.1:3306/yourdb?useSSL=false&serverTimezone=UTC
db.username=youruser
db.password=yourpass

# PostgreSQL
# db.driver=org.postgresql.Driver
# db.url=jdbc:postgresql://127.0.0.1:5432/yourdb

# SQL Server
# db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
# db.url=jdbc:sqlserver://127.0.0.1:1433;database=yourdb;encrypt=true;trustServerCertificate=true

# Oracle
# db.driver=oracle.jdbc.OracleDriver
# db.url=jdbc:oracle:thin:@//127.0.0.1:1521/ORCLPDB1

Build (Gradle)

# Gradle
./gradlew clean build -x test
# resulting JAR: build/libs/app.jar
java -jar build/libs/app.jar

Build (Maven)

# Maven
mvn -U -B -DskipTests package
# resulting JAR: target/app.jar
java -jar target/app.jar

Dockerfile (distroless, small & secure)

FROM eclipse-temurin:24-jre-jammy as build
WORKDIR /app
# copy your already-built jar here (Gradle or Maven)
COPY build/libs/app.jar /app/app.jar
# or: COPY target/app.jar /app/app.jar

FROM gcr.io/distroless/java24
WORKDIR /app
COPY --from=build /app/app.jar /app/app.jar
# nonroot user is pre-created in distroless
USER nonroot
# distroless provides "java" on PATH; no /bin/sh available
ENTRYPOINT ["java","-jar","/app/app.jar"]

Build & Run Container

# Build
docker build -t yourrepo/panaragan-app:1.0.0 .

# Run (mount .env as env-file)
docker run --rm -p 8080:8080 \
  --env-file ./.env \
  --name panaragan yourrepo/panaragan-app:1.0.0

Docker Compose (with PostgreSQL)

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: yourdb
      POSTGRES_USER: youruser
      POSTGRES_PASSWORD: yourpass
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U youruser -d yourdb"]
      interval: 5s
      timeout: 3s
      retries: 30

  app:
    image: yourrepo/panaragan-app:1.0.0
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:8080"
    env_file: .env
    environment:
      db.driver: org.postgresql.Driver
      db.url: jdbc:postgresql://db:5432/yourdb
      db.username: youruser
      db.password: yourpass

volumes:
  dbdata: {}

Nginx (reverse proxy & TLS)

server {
  listen 443 ssl http2;
  server_name yourdomain.tld;

  ssl_certificate     /etc/ssl/your.crt;
  ssl_certificate_key /etc/ssl/your.key;

  # Hardened defaults
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
  add_header X-Content-Type-Options nosniff always;
  add_header X-Frame-Options DENY always;
  add_header Referrer-Policy strict-origin-when-cross-origin always;

  location / {
    proxy_pass         http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto https;
    proxy_read_timeout 75s;
  }
}

server {
  listen 80;
  server_name yourdomain.tld;
  return 308 https://$host$request_uri;
}

Set headers in respond()

Panaragan can emit strict headers directly at the app layer:

// inside your controller or a global Respond helper
response.header("Strict-Transport-Security","max-age=63072000; includeSubDomains; preload");
response.header("X-Content-Type-Options","nosniff");
response.header("X-Frame-Options","DENY");
response.header("Referrer-Policy","strict-origin-when-cross-origin");
// cache control for static assets served by Panaragan
// response.header("Cache-Control","public, max-age=604800, immutable");

Systemd unit (Linux)

# /etc/systemd/system/panaragan.service
[Unit]
Description=Panaragan App
After=network.target

[Service]
User=panaragan
WorkingDirectory=/opt/panaragan
ExecStart=/usr/bin/java -jar /opt/panaragan/app.jar
Restart=always
RestartSec=3
# Let systemd capture stdout/stderr (journald)
StandardOutput=journal
StandardError=journal
SuccessExitStatus=143

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now panaragan
journalctl -u panaragan -f

Windows (NSSM)

# Install service with NSSM
nssm install Panaragan "C:\Program Files\Eclipse Adoptium\jre-24\bin\java.exe" -jar "C:\panaragan\app.jar"
nssm set Panaragan AppDirectory "C:\panaragan"
nssm start Panaragan

Basic health check

Use any public route that returns 200 (e.g., your home page) for probes.

#!/usr/bin/env bash
set -euo pipefail
curl -fsS -m 2 "http://127.0.0.1:8080/" >/dev/null

Container healthcheck

# add to Dockerfile if desired
HEALTHCHECK --interval=15s --timeout=3s --retries=10 \
  CMD ["bash","-lc","curl -fsS -m 2 http://127.0.0.1:8080/ >/dev/null"]

Performance & Operations Tips

  • JVM:Start with -Xms/-Xmx sized to 60–70% of container/VM RAM. G1GC works well for mixed workloads (-XX:+UseG1GC -XX:MaxGCPauseMillis=100). For latency-first, test ZGC on JDK if your environment supports it.
  • CPU pinning:In containers, limit CPUs via --cpus and benchmark Panaragan’s NIO selector threads vs core count.
  • DB connections:Size pool ≈cores × 2as a starting point; measure with your workload. Keep JDBC drivers local in libs/ if needed.
  • Static assets:Let Panaragan serve from resources/public; enable long-lived Cache-Control headers on hashed assets.
  • Security:Terminate TLS at the proxy; add strict headers in respond() as shown. Validate input at controllers. Return minimal error bodies in production.
  • Housekeeping:Use your Panaragan Job to expire sessions/tokens, purge temp uploads, and warm hot caches on start.
  • Zero-downtime:RunoldandnewJAR side-by-side on different ports and switch traffic at the proxy (blue/green), or roll containers with healthchecks.

Troubleshooting

  • 404 “Template not found”
    • Verify template.path in .env points to the base folder that Panaragan loads.
    • Match the exact view name you render (case-sensitive). Example: Template.render("backend.users.pdf", data) requires the corresponding view file packaged in resources.
    • If it runs in IDE but not from the JAR, ensure the view files are included in the built JAR (under resources/).
  • CSRF mismatch on POST (Blade view)
    • Inside every form in a Blade view, include the directive: @csrf (do not call Java helpers directly inside a view).
    • For file uploads, keep method="post" and add enctype="multipart/form-data" alongside @csrf.
    • If cookies are used for CSRF/session, ensure the domain/path/secure flags match how you access the app (HTTP vs HTTPS, domain vs subdomain).
  • Session not persisted
    • Set an appropriate session.mode in .env (consistent with how Panaragan manages session storage).
    • Make sure app.base_url matches the external host you use; otherwise cookies may not return with requests.
    • If served behind a reverse proxy, forward the original scheme and host so cookies/redirects are generated correctly.
  • DB connection issues
    • Confirm the JDBC driver matches your database vendor and is available on classpath.
    • Re-check JDBC URL/user/password formats for MySQL/PostgreSQL/SQL Server/Oracle as configured in your .env.
    • If a query fails on parameters, align placeholder counts in QueryBuilder.where(...) with the provided values (or use yourParamsObjectvariant you implemented).
    • If the database went down and came back, the pool will recover only after the DB is reachable again.
  • Email not sent
    • Verify SMTP host, port, username, password, and TLS setting in your configuration.
    • If you maintain multiple SMTP profiles, call the intended one via its index, e.g. Email.send(1, ...).
  • Upload blocked or rejected
    • Ensure the incoming MIME is in your whitelist for saveIfContentTypeMatch(...).
    • If you use a reverse proxy, verify its request/body size limit permits your file size.
  • Uploaded filename works on disk but 404 in URL (spaces, Unicode)
    • When generating links, encode each path segment (so my picture.jpeg becomes my%picture.jpeg).
    • When mapping URL → file path, decode after removing your upload slug. Example:
      // Map URL path to local file
      final String raw = request.path();                     // e.g. /uploads/users/1757460099365-my%picture.jpeg
      final String rel = raw.substring(UPLOAD_SLUG.length());
      final String filename = java.net.URLDecoder.decode(rel, java.nio.charset.StandardCharsets.UTF_8);
      // then resolve securely within your uploads root
    • Note: query parameter encoding andpath segmentencoding are not identical—encode/decode consistently for the use-case.