Why this Migration matters
CFML Custom tags were a great way to encapsulate UI fragments and reusable logic, but they come with limitations: implicit output, global-ish scopes (Attributes/Caller/ThisTag), difficulty unit-testing, and brittle parent/child associations. Migrating these constructs to ColdFusion Components (CFCs) gives you clearer APIs, type-checked arguments, better Encapsulation, and testability. It also modernizes your code base for frameworks, dependency injection, and Continuous delivery—while making it easier to run on both Adobe ColdFusion and Lucee.
Prerequisites / Before You Start
Versions and platform decisions
- Target CFML engine and version:
- Adobe ColdFusion 2018/2021/2023 recommended.
- Lucee 5.x recommended.
- Feature alignment you’ll rely on:
- new operator (Adobe CF 9+, Lucee 4.5+)
- Closures/lambdas for callbacks (Adobe CF 11+, Lucee 4.5+)
- Application.cfc mappings (both engines)
- If you must support very old engines:
- Use createObject(“component”, “path”) instead of new
- Use cfinvoke instead of calling methods directly
- Avoid closures; use savecontent and strings instead
Backups and source control
- Full repository backup and a tagged baseline (e.g., git tag pre-migrate-custom-tags).
- Database backup if any tags touch persistence (even read-only tags sometimes write logs or stats).
- Enable branch-based development; plan incremental merges.
Dependency inventory
- List every custom tag folder and tag names:
- Example folders: /customtags, /includes/tags, /lib/tags.
- Gather usage patterns like
and .
- Identify external dependencies in tags:
- Queries, services, gateways, cfhttp, filesystem access.
- Parent/child pairs using CFASSOCIATE.
- Reliance on Caller or Request scopes.
Tooling
- Code search utilities:
- Unit test harness ready (e.g., TestBox).
- Linting: CFLint or CommandBox cflint, if available.
- Local environments for Adobe CF and/or Lucee to validate compatibility.
Strategy and rollout
- Decide between:
- Big bang replacement.
- Strangler Migration (wrap existing tags, introduce CFCs, switch call sites gradually).
- Define acceptance criteria:
- Visual parity, no regressions, Performance equal or better, log cleanliness, test coverage.
Step-by-step Migration guide
Create a census of tags and categorize them:
- Simple presentational tags (no body content).
- Tag pairs with body content (use ThisTag.GeneratedContent).
- Nested parent/child tags (Tabs/Tab, Accordion/Pane) using CFASSOCIATE.
- Tags with side effects (writing files, database operations).
- Tags with implicit output or whitespace-sensitive output.
Document each tag’s:
- Inputs (Attributes scope).
- Output behavior (returns, writes output, modifies Caller).
- Dependencies (services, queries, external APIs).
- Edge cases (null inputs, optional attributes, Error handling).
2) Choose a migration pattern per tag
-
Option A: API-first CFC methods (preferred)
- Replace <cf_myTag …> with component method calls: new app.ui.MyTag().render(…).
- Pros: explicit API, easy to test, no secret scopes.
- Cons: requires updating call sites.
-
Option B: Wrapper custom tag
- Keep the custom tag name but move implementation to a CFC. The tag becomes a thin adapter.
- Pros: minimal template changes now; incremental migration.
- Cons: still allows tag usage; full removal deferred.
-
Option C: CFC-based custom tag via cfimport (engine-specific)
- Engines support using CFCs as custom tags via cfimport taglib=”…” prefix=”…”.
- Pros: tag-like usage, CFC lifecycle control.
- Cons: relies on engine lifecycle methods; consult vendor docs for onStartTag/onEndTag behavior.
Many teams combine B and A: introduce CFCs, wrap with tags temporarily, then refactor templates to call CFCs directly.
3) Create a CFC skeleton
- Place components under a mapped package, e.g., /com/acme/ui or /app/ui.
- Add an Application.cfc mapping:
- this.mappings[“/app”] = expandPath(“/com/acme”);
Skeleton:
cfml
// /com/acme/ui/Panel.cfc
component output=false accessors=true hint=”Renders a panel with header and body.” {
// Use init() if you need Configuration or DI later
public any function init() output=false {
return this;
}
public string function render(required string title, any body) output=false {
var content = “”;
// body can be a closure or a string
if (isCustomFunction(arguments.body)) {
savecontent variable=”content” {
arguments.body();
};
} else if (isSimpleValue(arguments.body)) {
content = arguments.body;
}
return '
‘ & encodeForHtml(arguments.title) & ‘
‘;
}
}
Notes:
- output=false keeps functions from writing implicitly; prefer returning strings.
- Use encodeForHtml for safety.
4) Map Attributes to arguments
Change from implicit Attributes scope to typed arguments:
- Before (custom tag .cfm):
- attributes.title, attributes.type
- After (CFC):
- function render(required string title, string type=”info”)
Validate inputs with cfargument and type hints. If you need runtime checks, add cfparam or throw descriptive errors (cfthrow type=”ValidationError”…).
5) Handle body content safely
Custom tags capture body content via ThisTag.GeneratedContent. In CFCs you have choices:
- Closure callback (requires ACF 11+ / Lucee 4.5+):
cfml
panel = new app.ui.Panel();
writeOutput(
panel.render(
title=”Welcome”,
body=function(){
writeOutput(“
Hello from a closure.
“);
}
)
);
- String body with savecontent:
cfml
savecontent variable=”bodyHtml” {
writeOutput(“
Hello without closures.
“);
}
panel = new app.ui.Panel();
writeOutput(panel.render(title=”Welcome”, body=bodyHtml));
Design your API to accept either. Document the preferred approach.
6) Replace nested parent/child patterns
Example: converting
-
Old pattern:
- CFASSOCIATE ties child to parent.
- ThisTag.ExecutionMode and HasEndTag manage lifecycle.
-
New CFC approach: aggregator object that collects children.
cfml
// /com/acme/ui/Tabs.cfc
component output=false {
variables.tabs = [];
public any function init() { variables.tabs = []; return this; }
public void function add(required string id, required string title, required any body) output=false {
var content = “”;
savecontent variable=”content” {
if (isCustomFunction(arguments.body)) { arguments.body(); }
else if (isSimpleValue(arguments.body)) { writeOutput(arguments.body); }
}
arrayAppend(variables.tabs, { id=arguments.id, title=arguments.title, content=content });
}
public string function render() output=false {
var html = “<div class=””tabs””>
- “;
- ‘ & encodeForHtml(t.title) & ‘
for (var t in variables.tabs) {
html &= ‘
‘;
}
html &= “
“;
for (var t2 in variables.tabs) {
html &= ‘
‘;
}
html &= “
“;
return html;
}
}
Usage:
cfml
tabs = new app.ui.Tabs().init();
tabs.add(id=”one”, title=”One”, body=function(){ writeOutput(“First tab”); });
tabs.add(id=”two”, title=”Two”, body=function(){ writeOutput(“Second tab”); });
writeOutput(tabs.render());
7) Manage output and returns
- Prefer pure functions returning strings or data. Then writeOutput at the call site.
- Set output=false on functions. If you must write output, do it in a controlled way and document it.
- Be aware of whitespace differences; consider this.whiteSpaceManagement = “smart” (Adobe CF) in Application.cfc to reduce accidental whitespace.
8) Wire into your app (mappings, DI, Application.cfc)
- Ensure Application.cfc contains mappings to your CFC packages:
cfml
component {
this.mappings[“/app”] = expandPath(“/com/acme”);
this.customTagPaths = [ expandPath(“/customtags”) ]; // keep until fully migrated
// Adobe CF only:
// this.whiteSpaceManagement = “smart”;
}
- If using DI frameworks (WireBox, ColdSpring, DI/1), register the new components and define scopes (transient vs singleton).
- Avoid storing request-specific state in variables scope of singleton CFCs.
9) Replace usages in templates
- Replace
with CFC calls. - Search/replace helpers:
- rg -n “<cf_myTag” ./views
- rg -n “cfmodule.*myTag” ./views
- Transitional approach:
- Wrap content in savecontent to pass as body string if closures aren’t available.
Example conversion:
Before:
cfml
Body goes here
After:
cfml
panel = new app.ui.Panel();
savecontent variable=”bodyHtml” {
writeOutput(‘
Body goes here
‘);
}
writeOutput(panel.render(title=”Welcome”, body=bodyHtml));
Or with closures:
cfml
panel = new app.ui.Panel();
writeOutput(panel.render(title=”Welcome”, body=function(){
writeOutput(‘
Body goes here
‘);
}));
For non-script pages, you can use cfinvoke:
cfml
Body goes here
10) Add unit tests
- Use TestBox to write tests for each migrated CFC method.
- Test Default values, edge cases, and HTML encoding behavior.
- Snapshot tests for HTML can catch markup drift.
11) Performance and caching
- If tags previously did expensive work (queries, file reads), consider:
- Caching results in application or cache region.
- Adding optional cacheKey argument to render() that the caller can supply.
- Load test critical pages After migration.
Example Migrations
A) Simple presentational tag (no body)
Old: /customtags/badge.cfm
cfml
New: /com/acme/ui/Badge.cfc
cfml
component output=false {
public string function render(required string text, string type=”info”) output=false {
return ‘‘ & encodeForHtml(text) & ‘‘;
}
}
Usage:
cfml
writeOutput( new app.ui.Badge().render(text=”New”, type=”success”) );
B) Body content tag
Old: /customtags/panel.cfm
cfml
<cfif thisTag.executionMode EQ “start”>
#encodeForHtml(attributes.title)#
New: Panel.cfc shown earlier; pass body as closure or string.
Old: /customtags/tabs.cfm and /customtags/tab.cfm with CFASSOCIATE.
New: Tabs.cfc aggregator shown earlier.
D) Wrapper custom tag to preserve legacy calls
Wrapper file: /customtags/panel.cfm
cfml
<cfif thisTag.executionMode EQ “start”>
panel = new app.ui.Panel();
html = panel.render(
title = attributes.title,
body = thisTag.generatedContent
);
writeOutput(html);
This lets you migrate implementation first and update templates later.
Risks, Common Issues, and How to Avoid Them
-
Scope leaks and shared state
- Risk: Migrated CFCs accidentally store request-specific data in variables scope of a singleton.
- Avoid: Mark such CFCs transient in DI; keep per-call data in local or arguments; do not store across requests.
-
Double output or missing output
- Risk: Function writes output and also returns string, leading to duplicates.
- Avoid: output=false; pick one: return a string or write explicitly.
-
Whitespace and layout drift
- Risk: Differences from generatedContent or new concatenations.
- Avoid: Use savecontent consistently; normalize whitespace; set this.whiteSpaceManagement=”smart” (Adobe CF).
-
Character encoding and XSS
- Risk: Losing encodeForHtml/encodeForHtmlAttribute calls when Refactoring.
- Avoid: Always encode user-controlled output; add tests to verify.
-
Caller or Request scope coupling
- Risk: Old tags reading/writing Caller or Request state implicitly.
- Avoid: Replace with explicit arguments and return values; pass dependencies explicitly.
-
Nested tag associations
- Risk: CFASSOCIATE semantics not replicated.
- Avoid: Replace with an aggregator object pattern (add + render), maintaining order.
-
Version-specific Features
- Risk: Using closures on older Adobe CF versions.
- Avoid: Use savecontent and strings or update engine; provide shims.
-
Performance regressions
- Risk: Repeated component creation in hot paths.
- Avoid: Reuse CFCs where safe; consider DI for lifecycle; cache expensive results.
-
Error handling differences
- Risk: Tags that relied on CF’s implicit error bubbling.
- Avoid: Wrap with try/catch, throw typed exceptions (cfthrow type=”…”), and log (cflog) appropriately.
Post-Migration Checklist / Validation Steps
-
Functional parity
- Pages render as before; visual diffs or snapshot tests pass.
- All custom tag usages replaced or wrapped.
-
Logging and errors
- No new stack traces in logs.
- Warnings and deprecation messages addressed.
-
Output correctness
- No double output; whitespace acceptable.
- Proper HTML encoding for dynamic values.
-
Performance
- Page timings within agreed thresholds.
- Confirm no excessive CFC instantiations; profile hot paths.
-
Tests
- Unit tests for each new CFC function.
- Integration tests for critical templates.
-
- Application.cfc mappings present and correct.
- DI registrations updated (if using WireBox/DI/1).
-
Cleanup
- Remove or mark deprecated: old tag files, cfmodule call sites.
- Documentation updated: new APIs, examples, and usage conventions.
Reference: Concept Mapping and Config Snippets
Common concept mapping
| Custom tag concept | CFC equivalent / approach |
|---|---|
| Attributes scope | Function arguments with types/defaults |
| ThisTag.GeneratedContent | body argument (closure or string) + savecontent |
| ThisTag.ExecutionMode | Not needed; explicit method API |
| CFASSOCIATE (parent/child) | Aggregator pattern: parent.add(child) + render() |
| Caller scope | Explicit dependencies or return values |
| cfmodule template=”…” | cfinvoke/new path.Component().method() |
| cfexit method=”exittag” | return early from function |
Application.cfc mapping
cfml
component {
this.name = “MyApp”;
this.mappings[“/app”] = expandPath(“/com/acme”);
// Keep until legacy tags are removed
this.customTagPaths = [ expandPath(“/customtags”) ];
// Adobe CF only:
// this.whiteSpaceManagement = “smart”;
}
Calling a CFC without closures (older engines)
cfml
Legacy compatible body
Using cfinvoke
cfml
Content
FAQ
Can I keep the custom tag Syntax but move logic to CFCs?
Yes. Create a thin wrapper custom tag that delegates to a CFC, as shown in the wrapper example. This lets you migrate the implementation first and update templates later. You can also explore CFC-based custom tags via cfimport to preserve tag-like usage; consult your engine’s docs for lifecycle specifics.
Replace the implicit association with an explicit aggregator object. The parent component exposes add(…) to collect child data and a render() method to output the final markup. This makes ordering and data flow explicit and testable.
What if my ColdFusion version doesn’t support closures?
Use savecontent to capture the body into a string and pass it as an argument. Alternatively, postpone closure adoption until you upgrade to Adobe CF 11+ or Lucee 4.5+.
Should my CFC methods return strings or write output?
Prefer returning strings with output=false and let the caller writeOutput. It keeps side effects predictable and simplifies testing. Only write output directly if absolutely necessary and document it.
How do I test migrated components?
Use a test framework like TestBox. Write unit tests for each method: verify Default values, encoding, error handling, and HTML structure (snapshot/string assertions). Integration tests can render key pages and compare expected HTML fragments.
