Skip to content

Plugin forms: validate and save data

This example extends the demo plugin with one editable greeting. It uses the table already created by its V1 migration. The setting is installation-wide; it is not a shop-specific value. For per-shop records, use the scope pattern in Database and migrations.

In src/providers/plugin_provider.cfc, append this struct to the array returned by getAdminRouteDefinitions(). Keep the existing GET route.

POST route
{
'name' = "plugin.demo.admin_integration.save",
'area' = "admin",
'methods' = ["POST"],
'path' = "/plugins/demo.admin_integration/save",
'controller' = "netseller_plugin_demo_admin_integration.controllers.demo_controller",
'action' = "save",
'middleware' = [
"admin.auth",
"admin.permission:plugin.demo.admin_integration.manage"
]
}

Append this to getAdminPermissionDefinitions():

Permission
{
'key' = "plugin.demo.admin_integration.manage",
'action' = "manage",
'defaultName' = "Manage demo plugin",
'defaultDescription' = "Change the demo greeting."
}

Reinitialize and enable the plugin again to register the new definitions. Grant both view and manage permissions to the role using this page.

Create src/services/greeting_service.cfc. This small service keeps validation and persistence out of the provider and view.

src/services/greeting_service.cfc
component output="false" {
public string function getGreeting(required string datasource) {
var rows = queryExecute(
"SELECT str_setting_value
FROM plg_demo_admin_integration_settings
WHERE str_setting_key = :setting_key",
{ 'setting_key' = {
'value' = "greeting", 'cfsqltype' = "cf_sql_varchar"
} },
{ 'datasource' = arguments.datasource }
);
return rows.recordCount ? rows.str_setting_value[1] : "";
}
public struct function saveGreeting(
required string datasource,
required string greeting
) {
var value = trim(arguments.greeting);
if (!len(value) or len(value) gt 255) {
return { 'success' = false };
}
queryExecute(
"INSERT INTO plg_demo_admin_integration_settings
(str_setting_key, str_setting_value)
VALUES (:setting_key, :setting_value)
ON DUPLICATE KEY UPDATE
str_setting_value = VALUES(str_setting_value)",
{
'setting_key' = {
'value' = "greeting", 'cfsqltype' = "cf_sql_varchar"
},
'setting_value' = {
'value' = value, 'cfsqltype' = "cf_sql_varchar"
}
},
{ 'datasource' = arguments.datasource }
);
return { 'success' = true };
}
}
src/controllers/demo_controller.cfc
component output="false" {
public struct function index() {
var service = new netseller_plugin_demo_admin_integration.services.greeting_service();
return renderPage(
greeting = service.getGreeting(request.database.datasourceName),
saved = structKeyExists(url, "saved") and url.saved eq "1"
);
}
public struct function save() {
var value = structKeyExists(form, "greeting") and isSimpleValue(form.greeting)
? trim(form.greeting & "") : "";
var service = new netseller_plugin_demo_admin_integration.services.greeting_service();
var result = service.saveGreeting(request.database.datasourceName, value);
if (!result.success) {
return renderPage(
greeting = value,
error = request.t(
"plugin.demo.admin_integration.greeting_invalid",
"Enter a greeting between 1 and 255 characters."
),
statusCode = 422
);
}
return {
'redirect' = request.buildUrl(
"plugin.demo.admin_integration.index", { 'saved' = 1 }
),
'statusCode' = 303
};
}
private struct function renderPage(
required string greeting,
string error = "",
boolean saved = false,
numeric statusCode = 200
) {
return {
'layout' = "../application/admin/views/layouts/app.cfm",
'view' = "/netseller_plugin_demo_admin_integration/views/demo/index.cfm",
'pageTitle' = request.t("plugin.demo.admin_integration.title", "Demo plugin"),
'pageHeading' = request.t("plugin.demo.admin_integration.title", "Demo plugin"),
'activeNav' = "settings",
'demoForm' = {
'greeting' = arguments.greeting,
'error' = arguments.error,
'saved' = arguments.saved
},
'statusCode' = arguments.statusCode
};
}
}

The route dispatcher calls actions without positional arguments. Read submitted form values from form, query values from url, and route parameters from request.currentRoute.params; validate them before use.

The GET loads the stored value. The POST validates and saves it. Invalid input returns the entered text with a 422 response; success redirects with 303 so a browser refresh does not submit the form again.

src/views/demo/index.cfm
<cfset state = routeConfig.demoForm>
<cfoutput>
<div class="page-body">
<div class="container-xl">
<cfif len(state.error)>
<div class="alert alert-danger" role="alert">#encodeForHtml(state.error)#</div>
</cfif>
<cfif state.saved>
<div class="alert alert-success" role="status">
#encodeForHtml(request.t("plugin.demo.admin_integration.saved", "Saved."))#
</div>
</cfif>
<cfif request.adminCan("plugin.demo.admin_integration.manage")>
<form method="post" action="#encodeForHtmlAttribute(request.buildUrl('plugin.demo.admin_integration.save'))#">
#request.renderCsrfTokenInput()#
<label class="form-label" for="greeting">
#encodeForHtml(request.t("plugin.demo.admin_integration.greeting", "Greeting"))#
</label>
<input class="form-control" id="greeting" name="greeting"
required maxlength="255" value="#encodeForHtmlAttribute(state.greeting)#">
<button class="btn btn-primary mt-3" type="submit">
#encodeForHtml(request.t("plugin.demo.admin_integration.save", "Save"))#
</button>
</form>
<cfelse>
<p>#encodeForHtml(state.greeting)#</p>
</cfif>
</div>
</div>
</cfoutput>

NetSeller checks CSRF before dispatching state-changing requests. Include request.renderCsrfTokenInput() in POST forms. For JavaScript requests, use request.getCsrfTokenHeaderName() and request.getCsrfToken() to prepare the header. Never use GET for saves.

The permission check in the view controls presentation. The POST route’s middleware enforces access even if someone submits the URL directly.

Add the labels used here to src/translations/en.json and your other language catalogs, keeping the same keys. The fallback strings make the example usable before you add catalogs.

After activating the updated plugin, open Demo plugin, enter a greeting and save. The value survives reopening the page. Empty input produces a validation message without replacing the stored value. A role with only view permission sees the value but cannot save it.