dmvcframework-webapp
Skill23 starsChanged 7 days agoApache-2.0View on GitHub
Use when building a server-side web application with DelphiMVCFramework — HTML pages and HTMX fragments rendered by TemplatePro. Covers view engine setup, template inheritance and blocks, ViewData and its ownership rules, fragments, custom filters, cookie/JWT login, static files, and the HTMX request/response helpers on the Delphi side. Triggers on "TemplatePro", "RenderView", "ViewData", "baselayout", "template inheritance", "HTML fragment", "web controller", "HTMX", "hx-get", "server-side view", "web application", "cookie auth", "static files".
- Reads credentials
1 other project uses a version of this, 1 of them edited.
Adopted by
Most recently taken up by
- danieleteti/delphimvcframeworkedited, first seen Nov 2, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 1
- danieleteti/delphimvcframework★ 1.4k99% same
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository15 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- delphi-buildSkill
- delphi-code-smellsSkill
- delphi-firedacSkill
- delphi-securitySkill
- delphiSkill
- delphi-testingSkill
- dmvcframework-jsonrpcSkill
- dmvcframework-minimal-apiSkill
- dmvcframework-securitySkill
- dmvcframeworkSkill
- dmvcframework-testingSkill
- dmvcframework-uiSkill
- exewatch-delphiSkill
- htmx-skillSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 3 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 35
Never invent an identifier or answer from memory.
- Distinctive: line 40
If you still cannot verify it, say so.
- Very specific: line 68
Read EngineConfigU.pas, the web controller and bin/templates/baselayout.html before writing anything.
- Distinctive: line 72
Do not scaffold one. Tell the user:
- Very specific: line 85
The host may be WebBroker (ISAPI, Apache) — that is fine: Keep it, do not migrate it, do not suggest migrating it.
- Distinctive: line 113
All controller actions are functions, never procedures.
- Very specific: line 916
Prefer injecting the service rather than resolving it by hand — [MVCInject] on the controller's constructor or on an action parameter.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
DMVCFramework — Server-Side Views with TemplatePro + HTMX
Reference project: webapplication_with_htmx wizard template.
TemplatePro repo: https://github.com/danieleteti/templatepro
HTMX docs: https://htmx.org/docs/
REQUIRED COMPANION SKILL: dmvcframework — covers the REST/ActiveRecord/middleware
layer that underpins every web application. If the user asks about controllers, entities,
JWT middleware configuration, or engine setup beyond what is in this skill, invoke dmvcframework.
COMPANION SKILL (on demand): htmx-skill — indexes the htmx 2.x reference pages
(attributes, events, headers, extensions, examples). For any attribute, trigger/swap modifier, event, header or extension beyond the few
patterns in Section 7, invoke it and read the linked page. Do not write HTMX from memory.
COMPANION SKILL (on demand): dmvcframework-ui — the presentation layer the wizard generates:
Bootstrap 5.3, baselayout.html blocks, style.css brand tokens, dark mode via data-bs-theme, toasts.
Invoke it before writing markup or CSS.
REQUIRED REFERENCE — dmvcframework-security. Any endpoint that accepts input from a client (body,
query string, header, cookie, upload, URL) must follow it: access control and IDOR, mass assignment,
SQL injection, XSS, CSRF, path traversal, uploads, security headers, JWT, secrets. Invoke it whenever you
write or review such an endpoint — not only when the user says "security".
When in doubt about an API — verify it, never guess
Never invent an identifier or answer from memory. If you need a signature this skill does not cover, ask the
user for the path to their DelphiMVCFramework checkout and read sources/ (and the matching samples/
project); failing that, read the official repository —
sources ·
samples.
If you still cannot verify it, say so. Where a skill and a sample disagree, the sample wins.
1. STOP — this skill works inside a wizard-generated project
Never create the project from scratch. Never hand-write the .dpr or the base templates.
The wizard generates a running web application — layout, static files, session, view engine, HTMX — and your
job is to add pages to it.
Step 1 — Detect the project in the current folder
The user is expected to have run the wizard and started the agent from inside the project folder. A web wizard project has:
*.dpr Boot + RunServer (Indy Direct by default; a WebModule here means WebBroker/ISAPI/Apache — also fine)
BootConfigU.pas dotEnv, LoggerPro, TemplatePro init
EngineConfigU.pas view engine, middleware/filters, UseExceptionHandler
Controllers.HomeU.pas web controller — actions returning RenderView(...)
Controllers.APIU.pas JSON sidecar (if present)
TemplateProHelpersU.pas custom TemplatePro filters
ServicesU.pas DI registrations
bin/.env port, secrets, view settings
bin/templates/ baselayout.html + pages
bin/www/ static files (css/, favicon, manifest)Read EngineConfigU.pas, the web controller and bin/templates/baselayout.html before writing anything.
Step 2 — If there is no wizard project, stop and say so
Do not scaffold one. Tell the user:
This skill works on a project generated by the DMVCFramework IDE wizard, and I do not see one in this folder. Please create it first:
Delphi IDE → File → New → Other → Delphi Projects → DelphiMVCFramework → New DMVCFramework Application
Pick Web Application (or Minimal API WebApp for lambda handlers). Accept the defaults — the server backend is Indy Direct. Compile and run it once and check the page opens in the browser. Then
cdinto the project folder, start me there, and tell me what page to add.
Then wait. Do not generate project files in the meantime.
The host may be WebBroker (ISAPI, Apache) — that is fine. Keep it, do not migrate it, do not suggest
migrating it. Everything above the host is identical on every backend. See dmvcframework,
reference/servers.md.
Step 3 — Follow the scaffolding workflow for new pages
See Section 16 for the step-by-step workflow.
2. Architecture Overview
Browser
│ HTML pages + HTMX attributes
│
├─ GET /web/... → TWebController → RenderView('path/template')
│ ViewData['key'] := value
│
├─ GET /web/... → TWebController → same action, fragment template
│ (HTMX request, IsHTMX and not HXIsBoosted → RenderView('x/table')
│ not boosted) otherwise → RenderView('x/index')
│
└─ GET /api/... → TAPIController → OkResponse(StrDict(...))
(JSON sidecar)Key invariants:
- All controller actions are functions, never procedures.
[MVCProduces(TMVCMediaType.TEXT_HTML)]is optional — the samples omit it and set the default content type once in the engine config (Config[TMVCConfigKey.DefaultContentType] := TMVCMediaType.TEXT_HTML) or inOnBeforeAction. Use it only when one action must differ from the app default.- Full-page actions call
Result := RenderView('folder/template'). - Fragment actions also call
RenderView: the same action rendersx/tableorx/index(§7). Do not hand-build HTML strings. procedureis allowed for redirects (Redirect('/people')), which produce no body.- The JSON API sidecar (
/api) uses standardIMVCResponsefactory methods.
3. Engine Configuration (EngineConfigU.pas)
procedure ConfigureEngine(AEngine: TMVCEngine);
var
lJWT: TMVCJWTCookieAuthenticationOptions;
lWwwPath: string;
begin
lWwwPath := TPath.Combine(AppPath, 'www');
// Controllers
AEngine.AddController(TWebController);
AEngine.AddController(TAPIController);
// Server-Side View Engine
AEngine.SetViewEngine(TMVCTemplateProViewEngine);
// Filter chain (engine-wide). Order matters: first = outermost
AEngine.Use(RedirectHTTPFilter(['/'], '/web'));
AEngine.Use(FileServerBundle('/static', lWwwPath)); // files + Range, before routing
AEngine.Use(CompressionHTTPFilter(1024));
AEngine.Use(FileSessionHTTPFilter(0, TPath.Combine(AppPath, 'sessions'),
SessionCookieFlags(dotEnv.Env('SESSION_COOKIE_SECURE', True)))); // HttpOnly, + Secure
// JWT in an HTTP-only cookie, one bundle: POST /login issues it and /logout expires it (before
// routing), every routed request is checked (after routing)
lJWT := TMVCJWTCookieAuthenticationOptions.Default; // Cookie: HttpOnly, Secure, SameSite=Strict
lJWT.Handler := TAuthentication.Create;
lJWT.ClaimsSetup := procedure(const JWT: TJWT) // what goes into every issued token
begin
JWT.Claims.Issuer := dotEnv.Env('JWT_ISSUER', 'MyApp');
JWT.Claims.ExpirationTime := Now + OneHour;
JWT.Claims.NotBefore := Now - OneMinute * 5;
JWT.Claims.IssuedAt := Now;
end;
lJWT.Secret := dotEnv.Env('JWT_SECRET'); // no fallback: a missing key must fail, not sign with a public string
lJWT.ClaimsToCheck := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore, TJWTCheckableClaim.IssuedAt];
lJWT.Cookie.Secure := dotEnv.Env('JWT_COOKIE_SECURE', True);
// only these origins may POST /login (403 otherwise)
lJWT.LoginAllowedOrigins := dotEnv.Env('LOGIN_ALLOWED_ORIGINS', '').Split([',']);
if Length(lJWT.LoginAllowedOrigins) = 0 then // the bundle would check no origin
raise Exception.Create('LOGIN_ALLOWED_ORIGINS is empty: set it in .env, the origins a browser may POST /login from');
AEngine.Use(JWTCookieAuthenticationBundle(lJWT));
// Browser-friendly error pages: renders templates/error.html for clients that prefer HTML,
// leaves the JSON error body untouched for API clients. One call — no handler to hand-write.
AEngine.UseExceptionHandler('error', 'MyApp');
end;A project from a 3.4 wizard has the same chain as classic middleware (TMVCRedirectMiddleware,
UseJWTCookieAuthentication, UseFileSessionMiddleware, TMVCStaticFilesMiddleware,
TMVCCompressionMiddleware): deprecated in 3.5, they still work, and each warning names its filter. Keep them
unless the user asks to migrate; in new code write the filters.
UseExceptionHandler(AErrorViewName = 'error'; AAppName = ''; AOptions = []) renders
<ViewPath>/error.<DefaultViewFileExtension> and exposes status, statustext, app_name,
dmvc_version, current_year to the template. error is empty unless you pass ehShowDetails in
AOptions — which you want in development and almost certainly not in production, since it leaks the
exception message to the browser. Reach for SetExceptionHandler(proc) only when you need
behaviour this cannot express.
Required units for EngineConfigU.pas:
uses
TemplatePro,
MVCFramework.View.Renderers.TemplatePro,
MVCFramework.Filters,
MVCFramework.Filters.Authentication,
MVCFramework.JWT,
MVCFramework.Commons,
System.SysUtils,
System.IOUtils,
System.DateUtils;4. Web Controller Pattern
unit Controllers.HomeU;
interface
uses MVCFramework, MVCFramework.Commons, MVCFramework.Serializer.Commons;
type
[MVCPath('/web')]
TWebController = class(TMVCController)
protected
procedure OnBeforeAction(Context: TWebContext;
const AActionName: string; var Handled: Boolean); override;
public
[MVCPath]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function Index: String;
[MVCPath('/about')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function About: String;
// Fragment endpoint — a partial TEMPLATE, rendered with RenderView (see section 9)
[MVCPath('/fragment/clock')]
[MVCHTTPMethod([httpGET])]
function GetClockFragment: String;
end;
implementation
uses System.SysUtils, System.DateUtils, MVCFramework.Logger;
procedure TWebController.OnBeforeAction(Context: TWebContext;
const AActionName: string; var Handled: Boolean);
begin
inherited;
// Set ViewData available to ALL views via baselayout.html
ViewData['app_name'] := 'MyApp';
ViewData['dmvc_version'] := DMVCFRAMEWORK_VERSION;
ViewData['current_year'] := YearOf(Now);
ViewData['page_id'] := AActionName.ToLower; // for aria-current="page"
end;
function TWebController.Index: String;
begin
ViewData['current_date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
Result := RenderView('home/index'); // renders templates/home/index.html
end;
function TWebController.About: String;
begin
Result := RenderView('about/index');
end;
function TWebController.GetClockFragment: String;
begin
ViewData['time'] := FormatDateTime('hh:nn:ss', Now);
ViewData['date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
Result := RenderView('home/_clock'); // templates/home/_clock.html — no {{extends}}, so no chrome
end;Passing objects and lists to templates
// Scalar values
ViewData['title'] := 'My Page';
ViewData['count'] := 42;
// Object — template accesses properties with dot notation
ViewData['person'] := lPerson;
// List — template iterates with {{for item in list}}
ViewData['people'] := lPeople; // TObjectList<T>ViewData does NOT own what you put in it. TMVCViewDataObject is a
TObjectDictionary<string, TValue> constructed with no ownership flags
(MVCFramework.Commons.pas) — nothing is freed for you. Free objects yourself, after the render:
function TWebController.Customers: String;
var
lCustomers: TObjectList<TCustomer>;
begin
lCustomers := GetCustomers;
try
ViewData['customers'] := lCustomers;
Result := RenderView('customers'); // render happens here, inside the try
finally
lCustomers.Free;
end;
end;A TDataSet (e.g. a TFDMemTable) can be passed as ViewData the same way, and iterated with {{for}}.
Accessing session data and current user in a controller
// After JWTCookieAuthenticationBundle (or UseJWTCookieAuthentication), the session is validated automatically.
// Access claims from the JWT token:
var lUsername := Context.LoggedUser.UserName;
var lRoles := Context.LoggedUser.Roles; // TList<string>
var lCustom := Context.LoggedUser.CustomData['key'];Form posts — validate and re-render with errors
A posted form is not validated automatically (only [MVCFromBody] is). The framework sample
samples/webapp_htmx_forms shows the pattern (ReadPerson in PeopleSampleU.pas):
- Copy
Context.Request.ContentFields(keys are lowercase field names) into an object whose properties carry validator attributes (MVCFramework.Validators). TMVCValidationEngine.Validate(lModel, lErrors)(MVCFramework.ValidationEngine):lErrorsis nil when valid, otherwise aTDictionary<string, string>you free, keyed by property name (Email).- Copy the messages into your error map with
LowerCase(Key)— the forms library (lib/forms_bootstrap5.tpro) looks them up by the control'sname. - Valid → save, then
RedirectResponse(...)(Post/Redirect/Get). Invalid →ViewData['formModel'] := Context.Request.ContentFields(shows what was typed),ViewData['formErrors'] := lErrors, render the same view with status 422 (TMVCHTMLResponsewithStatusCode := HTTP_STATUS.UnprocessableEntity).
MVCRequired passes on Integer/TDate (0 is a value): parse those fields yourself and add a message when
the text does not convert. Full code (controller and Minimal API): the dmvcframework skill,
reference/validation.md, "HTML form post".
5. TemplatePro Template Syntax
Output a variable
{{:variable_name}}
{{:person.FirstName}}
{{:person.Address.City}}Template inheritance
Base layout (baselayout.html) defines named blocks:
<!DOCTYPE html>
<html>
<head>
<title>{{block "title"}}{{:app_name}}{{endblock}}</title>
</head>
<body>
<main>{{block "body"}}{{endblock}}</main>
{{block "scripts"}}{{endblock}}
</body>
</html>Child template overrides blocks:
{{extends "../baselayout.html"}}
{{block "title"}}My Page{{endblock}}
{{block "body"}}
<h1>Hello from {{:app_name}}</h1>
{{endblock}}{{extends "path"}}— path is relative to the current template file.error.htmlextends with{{extends "baselayout.html"}}(same folder).- Subfolder templates use
{{extends "../baselayout.html"}}.
Conditionals
{{if page_id|eq,"index"}}aria-current="page"{{endif}}
{{if user_logged_in}}
<p>Welcome, {{:username}}!</p>
{{else}}
<p><a href="/login">Log in</a></p>
{{endif}}Available comparison operators for |eq, |ne, |gt, |lt, |ge, |le:
{{if count|gt,0}}...{{endif}}
{{if role|eq,"admin"}}...{{endif}}Loops
{{for person in people}}
<li>{{:person.FirstName}} {{:person.LastName}}</li>
{{endfor}}
{{# Loop index: it is a pseudo-property OF THE LOOP VARIABLE, and it is 1-based #}}
{{for item in items}}
<tr>
<td>{{:item.@@index}}</td>
<td>{{:item.Name}}</td>
</tr>
{{endfor}}
{{# ...so it composes with filters #}}
{{if item.@@index|lt,4}}<span class="badge">top</span>{{endif}}Filters (built-in)
{{:name|uppercase}}
{{:name|lowercase}}
{{:name|capitalize}}
{{:name|rpad,10}} {{:code|lpad,3,"0"}}
{{:date|datetostr}} {{:ts|datetimetostr}} {{:ts|formatdatetime,"dd/mm/yyyy"}}
{{# Filters DMVCFramework adds on top of TemplatePro (MVCFramework.View.Renderers.TemplatePro) #}}
{{:obj|json}} {{:s|urlencode}} {{:list|count}} {{:name|fromquery}}
{{# Comparison filters work on dates too #}}
{{if cust.dob|ge,"2000/01/01"}}...{{endif}}
{{# Output is HTML-escaped by default. Suffix $ to emit raw HTML #}}
{{:myobj.rawhtml$}}
{{# Negation #}}
{{if !person}}<p>Not found</p>{{endif}}
{{:value|default,"N/A"}}Custom filters (registered in TemplateProHelpersU.pas)
{{:value|MyHelper1}}
{{:value|MyHelper2}}Comments
{{# This is a comment — not rendered in HTML output #}}Include (partial template)
{{include "partials/card.html"}}6. Template File Structure
bin/templates/
baselayout.html ← base layout; all pages extend this
error.html ← browser error page (extends baselayout.html)
home/
index.html ← extends "../baselayout.html"
about/
index.html ← extends "../baselayout.html"
myfeature/
index.html ← extends "../baselayout.html"
_card.html ← reusable partial (prefix _ by convention)The view path is configured in .env:
dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false # set to true in production for performanceRenderView('home/index') resolves to templates/home/index.html.
7. HTMX — the patterns this stack actually uses
HTMX is loaded in baselayout.html; child templates need no setup.
For any attribute, trigger modifier, swap mode, event or extension: invoke the htmx-skill and read
the linked htmx page. It indexes the htmx 2.x reference pages. Do not write HTMX attributes
from memory — inheritance rules and swap/trigger modifiers are easy to get subtly wrong. What follows is
only the handful of patterns that carry a Delphi-side implication.
The one pattern that shapes your controller: page or fragment, one action
The same action serves the full page and the HTMX fragment — the shape of the People table in
samples/webapp_htmx_forms (Controllers.PeoplePagesU, people/index.html + people/table.html).
Below with customers. Two templates: customers/index.html is the page (it {{extends
"../baselayout.html"}} and {{include "table.html"}}), customers/table.html is the part that changes and
has no {{extends}}. You do not write a separate fragment endpoint, and you do not build HTML strings.
function TWebController.Customers: String;
var
lCustomers: TObjectList<TCustomer>;
begin
lCustomers := FCustomerService.GetAll;
try
ViewData['customers'] := lCustomers;
// HTMX asks for the table only; a normal request gets the whole page
if Context.Request.IsHTMX and not Context.Request.HXIsBoosted
and not Context.Request.HXIsHistoryRestoreRequest then // uses MVCFramework.HTMX
Result := RenderView('customers/table')
else
Result := RenderView('customers/index');
finally
lCustomers.Free; // ViewData owns nothing
end;
end;A plain browser navigation gets the whole document; an hx-get gets just the table. Test HXIsBoosted
too: a link under hx-boost is an HTMX request that expects the whole page — checking IsHTMX alone
serves it a fragment without the layout. And HXIsHistoryRestoreRequest: on a history-cache miss (Back
after the cache is full) htmx re-requests the URL with HX-Request: true and swaps the answer into body, so
it needs the whole page too. Because the same URL answers with a page or a fragment, also send
Vary: HX-Request.
Triggering the request
<!-- click -->
<button hx-get="/web/customers" hx-target="#list" hx-swap="innerHTML">Load</button>
<!-- on load / polling -->
<div hx-get="/web/stats" hx-trigger="load"></div>
<div hx-get="/web/stats" hx-trigger="every 5s"></div>
<!-- form post: response replaces the form -->
<form hx-post="/web/customers" hx-target="#result" hx-swap="outerHTML">…</form>
<!-- destructive action -->
<button hx-delete="/web/customers/1" hx-confirm="Delete this customer?" hx-target="closest tr"
hx-swap="outerHTML">Delete</button>hx-put/hx-patch/hx-delete reach the matching [MVCHTTPMethod] action directly — no method-override
hidden field, unlike a plain HTML form.
URL in the address bar
<a hx-get="/web/customers" hx-target="#main" hx-push-url="true">Customers</a>Without hx-push-url, the fragment loads but the URL does not change and the page is not bookmarkable.
Loading indicator
htmx-request is added to the element for the duration of the request; style it in style.css:
.htmx-request { opacity: 0.5; transition: opacity 0.3s; }Live updates: SSE, not polling
For server-pushed updates use Server-Sent Events. Do not hand-roll the stream in the action — inherit
TMVCSSEController (see the dmvcframework skill, reference/sse.md) and consume it with the htmx SSE
extension (https://htmx.org/extensions/sse/).
8. HTMX response helpers (from the Delphi side)
uses MVCFramework.HTMX; — it adds typed helpers to Context.Request / Context.Response.
Use them; do not hand-write HX-* header strings (a typo in a header name fails silently).
// full page reload, like window.location — e.g. after login
Context.Response.HXSetRedirect('/web/dashboard');
// HTMX-aware navigation: no full reload, content swapped, history updated
Context.Response.HXSetLocation('/web/products');
// URL bar only
Context.Response.HXSetPushUrl('/web/items/' + ItemID);
Context.Response.HXSetReplaceUrl('/web/items/' + ItemID);
// client-side events (see the dmvcframework-ui skill for showToast — it takes HTML)
Context.Response.HXTriggerClientEvent('refreshCart');
Context.Response.HXTriggerClientEvents(['refreshCart', 'updateBadge']);
// With a payload. It is serialized as a JSON *value*: a string arrives as evt.detail.value.
// Pass an object when you want named properties (evt.detail.FirstName, ...).
Context.Response.HXTriggerClientEvent('showMessage', 'Customer saved');
Context.Response.HXTriggerClientEvent('customerSaved', lCustomer);
// when it fires: etReceived (default), or after swap / after settle — pass the TClientEventType
Context.Response.HXTriggerClientEvent('initDatePicker', etSettled);
// override the swap / target the server chose on the client
Context.Response.HXSetReswap(TSwapOption.soOuterHTML);
Context.Response.HXSetRetarget('#other-element');
// tell the client to refresh the page
Context.Response.HXSetPageRefresh;On the request side: Context.Request.IsHTMX, HXIsBoosted, HXGetTarget, HXGetPrompt,
HXGetTriggeringEventAsJSON.
To do nothing (leave the DOM as it is), answer 204:
Context.Response.StatusCode := HTTP_STATUS.NoContent;
Result := '';9. Fragments — a template, never a string
A fragment is a template, rendered by the same view engine as a full page. Never concatenate HTML in
Delphi, and never call TTProCompiler.CompileAndRender yourself — that bypasses the engine, its cache and
its filters, and it forces you to hand-escape every value.
Two ways to serve one, both RenderView:
a) Same action, page or fragment. Preferred: one URL, bookmarkable, works without JS.
function TWebController.Items: String;
var
lItems: TObjectList<TItem>;
begin
lItems := FItemService.GetAll;
try
ViewData['items'] := lItems;
if Context.Request.IsHTMX and not Context.Request.HXIsBoosted then
Result := RenderView('items/table')
else
Result := RenderView('items/index');
finally
lItems.Free; // ViewData owns nothing
end;
end;b) A dedicated fragment action, when the fragment has no page of its own (a row after an edit, a search-results list). Point it at a partial template and skip the layout:
[MVCPath('/fragment/item-list')]
[MVCHTTPMethod([httpGET])]
function TWebController.GetItemList: String;
var
lItems: TObjectList<TItem>;
begin
lItems := FItemService.GetAll;
try
ViewData['items'] := lItems;
Result := RenderView('items/_list'); // a template with no {{extends}} → no chrome
finally
lItems.Free;
end;
end;{{# templates/items/_list.html — the fragment, and reusable via {{include}} from the page #}}
<ul id="item-list">
{{for item in items}}
<li id="item-{{:item.ID}}">
{{:item.Name}}
<a hx-delete="/web/items/{{:item.ID}}" hx-target="#item-{{:item.ID}}"
hx-swap="outerHTML" hx-confirm="Delete?">[x]</a>
</li>
{{endfor}}
</ul>TemplatePro escapes {{:item.Name}} for you — there is no HTMLEscape call to forget. The same partial
can be {{include}}d by the full page, so page and fragment never drift apart.
10. Cookie JWT Authentication
AuthenticationU.pas — the three handler methods
type
TAuthentication = class(TInterfacedObject, IMVCAuthenticationHandler)
protected
procedure OnRequest(
const AContext: TWebContext;
const ControllerQualifiedClassName, ActionName: string;
var AuthenticationRequired: Boolean);
procedure OnAuthentication(
const AContext: TWebContext;
const UserName, Password: string;
UserRoles: TList<string>;
var IsValid: Boolean;
const SessionData: TSessionData);
procedure OnAuthorization(
const AContext: TWebContext;
UserRoles: TList<string>;
const ControllerQualifiedClassName, ActionName: string;
var IsAuthorized: Boolean);
end;OnRequest — which routes need auth
procedure TAuthentication.OnRequest(
const AContext: TWebContext;
const ControllerQualifiedClassName, ActionName: string;
var AuthenticationRequired: Boolean);
begin
// Protect everything under TAdminController
AuthenticationRequired :=
ControllerQualifiedClassName.Contains('TAdminController');
end;OnAuthentication — validate credentials
procedure TAuthentication.OnAuthentication(
const AContext: TWebContext;
const UserName, Password: string;
UserRoles: TList<string>;
var IsValid: Boolean;
const SessionData: TSessionData);
begin
// validate the credentials against your user store
IsValid := ValidateFromDB(UserName, Password);
if IsValid then
begin
UserRoles.Add('user');
if IsAdmin(UserName) then
UserRoles.Add('admin');
SessionData.AddOrSetValue('username', UserName);
end;
end;OnAuthorization — check roles per action
procedure TAuthentication.OnAuthorization(
const AContext: TWebContext;
UserRoles: TList<string>;
const ControllerQualifiedClassName, ActionName: string;
var IsAuthorized: Boolean);
begin
if ActionName.StartsWith('Admin') then
IsAuthorized := UserRoles.Contains('admin')
else
IsAuthorized := True;
end;Login form pattern (browser-facing)
<form hx-post="/login" hx-swap="none"
hx-on::after-request="if (event.detail.successful) window.location.href = '/web'">
<input name="jwtusername" type="text" required>
<input name="jwtpassword" type="password" required>
<button type="submit">Login</button>
</form>The /login route is handled by the login of JWTCookieAuthenticationBundle (by UseJWTCookieAuthentication in a 3.4 project),
which a plain HTML form does not fit:
- Field names. Form data is read only as
jwtusername/jwtpassword(TMVCJWTDefaults.USERNAME_HEADER/PASSWORD_HEADER);username/passwordare accepted only in a JSON body. A form postingusernamegets 401. - No redirect. On success it sets the HTTP-only cookie and answers
200with JSON{"token": "..."}. The page must navigate by itself — hencehx-swap="none"and theafter-requesthandler above. A plain<form method="POST">would leave the user looking at the JSON. A failed login answers 401.
Reading the current user in a controller
function TWebController.Profile: String;
begin
ViewData['username'] := Context.LoggedUser.UserName;
ViewData['is_admin'] := Context.LoggedUser.Roles.Contains('admin');
Result := RenderView('profile/index');
end;11. Custom TemplatePro Filters (TemplateProHelpersU.pas)
Registering filters
The wizard already generates TemplateProContextConfigure in TemplateProHelpersU.pas and calls it once in
Boot (BootConfigU.pas). Add your AddFilter calls inside its existing handler — never assign
TTProConfiguration.OnContextConfiguration a second time. The generated handler also sets
CompiledTemplate.IncludeRootPath, which keeps a dynamic {{include}} built from request data inside the
views folder; a replacement handler silently drops that protection.
TTProConfiguration.OnContextConfiguration := procedure(const CompiledTemplate: ITProCompiledTemplate)
begin
CompiledTemplate.IncludeRootPath := lViewPath; // generated - keep it
CompiledTemplate.AddFilter('currency', CurrencyFilter);
CompiledTemplate.AddFilter('timeago', TimeAgoFilter);
// ... rest of the generated handler
end;Writing a filter
uses System.Rtti, TemplatePro, TemplatePro.Types;
function CurrencyFilter(const Value: TValue;
const Parameters: TArray<TFilterParameter>): TValue;
begin
Result := FormatCurr('€#,##0.00', Value.AsExtended);
end;
function TimeAgoFilter(const Value: TValue;
const Parameters: TArray<TFilterParameter>): TValue;
var
lDelta: TTimeSpan;
begin
lDelta := TTimeSpan.Subtract(Now, Value.AsExtended);
if lDelta.TotalMinutes < 1 then
Result := 'just now'
else if lDelta.TotalHours < 1 then
Result := Format('%d minutes ago', [Round(lDelta.TotalMinutes)])
else
Result := Format('%d hours ago', [Round(lDelta.TotalHours)]);
end;Template usage:
{{:price|currency}}
{{:created_at|timeago}}Dynamic external data source (OnGetValue)
CompiledTemplate.OnGetValue :=
procedure(const DataSource, Members: string;
var Value: TValue; var Handled: Boolean)
begin
if SameText(DataSource, 'config') then
begin
Value := GetConfigValue(Members); // e.g. 'config.siteName'
Handled := True;
end;
end;Template: {{:config.siteName}}
12. DI / Services Pattern (ServicesU.pas)
Registering services
procedure RegisterServices(Container: IMVCServiceContainer);
begin
Container.RegisterType(
TPeopleService, IPeopleService, TRegistrationType.SingletonPerRequest);
Container.RegisterType(
TProductService, IProductService, TRegistrationType.Singleton);
end;Registration types:
SingletonPerRequest— one instance per HTTP request (most common for web)Singleton— one instance for the lifetime of the processTransient— new instance on everyResolvecall
Resolving in a controller
function TWebController.Products: String;
var
lService: IProductService;
lProducts: TObjectList<TProduct>;
begin
lService := Context.ServiceContainerResolver.Resolve(TypeInfo(IProductService)) as IProductService;
lProducts := lService.GetAll;
try
ViewData['products'] := lProducts;
Result := RenderView('products/index');
finally
lProducts.Free; // ViewData owns nothing
end;
end;Registering — once, in the .dpr
TWebContext has no ServiceContainer property (only the read-only ServiceContainerResolver).
Registration happens once, before the server starts, and Build is mandatory:
RegisterServices(DefaultMVCServiceContainer);
DefaultMVCServiceContainer.Build;Prefer injecting the service rather than resolving it by hand — [MVCInject] on the controller's
constructor or on an action parameter. See the dmvcframework skill, reference/di-and-repository.md.
13. API JSON Sidecar (Controllers.APIU.pas)
The web application includes a JSON API controller alongside the web controller.
[MVCPath('/api')]
TAPIController = class(TMVCController)
public
[MVCPath('/server/info')]
[MVCHTTPMethod([httpGET])]
function GetServerInfo: IMVCResponse;
end;
function TAPIController.GetServerInfo: IMVCResponse;
begin
Result := OkResponse(
StrDict(
['application', 'serverTime', 'dmvcVersion'],
['MyApp', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now), DMVCFRAMEWORK_VERSION]
)
);
end;StrDict(keys, values) returns a TMVCStringDictionary (string→string), serialized as a flat JSON
object. For a dictionary of objects, use ObjectDict() — that is the one returning IMVCObjectDictionary.
14. Static Files
Static files are served from bin/www/ at the /static URL prefix.
bin/www/
css/style.css → /static/css/style.css
favicon.ico → /static/favicon.ico
favicon-32x32.png → /static/favicon-32x32.png
site.webmanifest → /static/site.webmanifestDeclaration (MVCFramework.Filters; a 3.4 project has the deprecated TMVCStaticFilesMiddleware):
AEngine.Use(FileServerBundle('/static', TPath.Combine(AppPath, 'www')));FileServerBundle sends the MIME type, Last-Modified (304 on a conditional GET), the default document of a
folder, refuses path traversal, and answers 206 to a Range request (audio, video, resumable downloads).
StaticFilesHTTPFilter('/static', ...) is the same without Range. FileServerBundle('/', lWwwPath, fsmSPA) serves a
single-page app: a GET/HEAD with no route gets index.html, the API routes stay. More switches in
TMVCFileServerOptions (Flags, Rules, MediaTypesCustomizer).
In templates: <link rel="stylesheet" href="/static/css/style.css">
15. Environment Configuration (bin/.env)
Full reference — profiles, precedence, ${}/$[] syntax, RequireKeys, the Boot order:
dmvcframework skill, reference/dotenv.md.
Key settings for the webapp template:
# Server
dmvc.server.port=8080
# View engine
dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false # true in production (caches compiled templates)
# JWT Cookie Auth - the wizard writes a random 384-bit key here; give every environment its own
# (e.g. openssl rand -hex 48) and keep .env out of version control
JWT_SECRET=<generated>
JWT_ISSUER=MyApp
# Set to false only for local development over plain HTTP.
JWT_COOKIE_SECURE=true
# Origins (comma separated) a browser may POST /login from; any other origin is
# refused (login CSRF). Add the public address of the site, e.g. https://www.example.com
LOGIN_ALLOWED_ORIGINS=http://localhost:8080,https://localhost:8080
SESSION_COOKIE_SECURE=true
# Logging
logger.config.file=loggerpro.jsonProfile-specific overrides: .env.test, .env.prod (layered by UseProfile).
16. Scaffolding Workflow — Adding a New Page
Step 1 — Add actions to the web controller
In Controllers.HomeU.pas (or a new controller unit):
[MVCPath('/products')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function Products: String;
[MVCPath('/products/fragment/list')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function GetProductListFragment: String;function TWebController.Products: String;
begin
ViewData['page_id'] := 'products';
Result := RenderView('products/index');
end;
function TWebController.GetProductListFragment: String;
var
lProducts: TObjectList<TProduct>;
begin
lProducts := fProductService.GetAll;
try
ViewData['products'] := lProducts; // ViewData owns nothing — free it below
Result := RenderView('products/_list'); // partial template, no {{extends}}
finally
lProducts.Free;
end;
end;Step 2 — Create the template
Create bin/templates/products/index.html:
{{extends "../baselayout.html"}}
{{block "title"}}Products{{endblock}}
{{block "body"}}
<div class="hero">
<h1>Products</h1>
</div>
<div hx-get="/web/products/fragment/list"
hx-trigger="load"
hx-swap="innerHTML">
<p aria-busy="true">Loading products...</p>
</div>
{{endblock}}Step 3 — Add nav link in baselayout.html
<li>
<a href="/web/products"
{{if page_id|eq,"products"}}aria-current="page"{{endif}}>
Products
</a>
</li>Step 4 — Register new controller (if separate unit)
In EngineConfigU.pas:
AEngine.AddController(TProductsController);17. Common Pitfalls
| Pitfall | Fix |
Action is procedure instead of function | All web actions must be function: return String for HTML, IMVCResponse for JSON. |
Freeing nothing after ViewData['x'] := lObj | ViewData owns nothing. Free the object yourself in a finally after RenderView. |
| Hand-building HTML for HTMX fragments | Same action, two templates: IsHTMX and not HXIsBoosted and not HXIsHistoryRestoreRequest → RenderView('x/table'), otherwise RenderView('x/index'). |
Writing HX-* headers by hand | uses MVCFramework.HTMX — Response.HXSetRedirect/HXSetPushUrl/HXSetReswap/HXSetRetarget/HXTriggerClientEvent, Request.IsHTMX/HXGetTarget. |
ViewData not set before RenderView | OnBeforeAction sets shared data; page-specific data must be set in the action function body before Result := RenderView(...). |
| Template extends wrong relative path | home/index.html → {{extends "../baselayout.html"}}. error.html (same folder as baselayout) → {{extends "baselayout.html"}}. |
JWT_COOKIE_SECURE=true with HTTP | Cookie won't be sent by the browser over plain HTTP. Use false during local dev, true in production with HTTPS. |
| View cache enabled during development | dmvc.view_cache=true caches compiled templates — template changes won't appear until restart. Use false in dev. |
SetExceptionHandler handles all requests | Check WebContext.Request.ClientPreferHTML first and Exit if false — otherwise API clients get an HTML error page. |
| Static file not found | File must be under bin/www/. The URL prefix is /static, not /www. FileServerBundle answers before routing, so the check of the authentication bundle never sees it; declare it after RedirectHTTPFilter. |
| Template variable silently empty | TemplatePro doesn't error on missing keys by default. Add a breakpoint after ViewData[...] assignments to verify they are set. |
{{for item in list}} doesn't iterate | The list must be a TObjectList<T> or array registered in ViewData. Ensure the service returns a non-nil, non-empty list. |
| HTMX response headers ignored | HTMX checks for HX-* headers only on responses to HTMX requests (requests with HX-Request: true header). Verify the button/div has a hx-* trigger attribute. |
hx-swap-oob elements not swapped | Each oob element must have an id that matches an existing DOM element. HTMX swaps by ID. |
18. Key Units Reference
| Unit | Purpose |
MVCFramework.View.Renderers.TemplatePro | TMVCTemplateProViewEngine |
TemplatePro | TTProCompiler.CompileAndRender, TTProConfiguration, ITProCompiledTemplate |
TemplatePro.Types | TFilterParameter |
MVCFramework.Filters | RedirectHTTPFilter, FileServerBundle, StaticFilesHTTPFilter, CompressionHTTPFilter, FileSessionHTTPFilter, SessionCookieFlags, TMVCAuthCookieOptions |
MVCFramework.Filters.Authentication | JWTCookieAuthenticationBundle, TMVCJWTCookieAuthenticationOptions |
MVCFramework.Middleware.* | The classic middleware of 3.4 projects (UseJWTCookieAuthentication, UseFileSessionMiddleware, TMVCStaticFilesMiddleware, …) — deprecated in 3.5 |
MVCFramework.JWT | TJWT, TJWTClaimsSetup, TJWTCheckableClaim |
MVCFramework.Commons | TMVCMediaType, AppPath, DMVCFRAMEWORK_VERSION, dotEnv, dotEnvConfigure |
MVCFramework.Container | IMVCServiceContainer, TRegistrationType |
MVCFramework.DotEnv | NewDotEnv (the builder) |
LoggerPro.Config | TLoggerProConfig.BuilderFromJSONFile |
19. Sample Reference Project
In the DelphiMVCFramework repository (https://github.com/danieleteti/delphimvcframework/tree/master/samples):
| Sample | What it shows |
samples/htmx_website_with_templatepro/ | TemplatePro + HTMX website: baselayout with blocks, partials, custom filters. Its ispage flag predates the wizard's page/table pattern and ignores hx-boost — follow the wizard |
samples/serversideviews_templatepro/ | The view engine in depth: inheritance, filters, RenderViews, CSV/non-HTML views, dataset in ViewData |
samples/wizard_showcase/web/ | Minimal-API flavour of a web app: .AsWeb, sessions, RenderView from lambda handlers |
Or generate one with the IDE wizard (preset Web Application) — that is the layout this skill assumes.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

