1 Basics of template programming
What a template is
A template is a text document – usually HTML – that contains CMS commands. The commands query records, insert contents, include other templates, evaluate forms and control the flow. PHP code may also appear in between.
(CMS:INCLUDE header)
<h1>(CMS:TEXT title)</h1>
<div>(CMS:TEXT text)</div>
<div>(CMS:MEDIA field=image_small attr[id]=image1)</div>
<div>created: (CMS:DATE created_at format="MM/dd/y")</div>
(CMS:INCLUDE footer)
When you save, camindo CMS translates the template into PHP. At runtime only this PHP is executed – there is no template engine that evaluates the text again on every call.
How a page is created
When a visitor opens an address, camindo CMS determines:
- the instance – from the domain,
- the language – from the language code in the address, otherwise from the browser's language setting, otherwise the primary language,
- the record – from the slug or the number (
record_id) in the address; without one, the start record of the instance, - the template – from the address, from the record, or the default template of its content area.
The contents of the requested record are available in the template. By default, all output
commands refer to this record – see (CMS:TEXT title) above. If the template runs a query
with CMS:SELECT, the contents of the records found are available in the same way in the
result template; see Lists, SELECT and the result loop below.
Addresses
[/{language}]/{slug or number}[,{template}]
| Address | Meaning |
|---|---|
/ |
Start page of the instance |
/company-party-2026 |
Record with this slug, default template |
/events/company-party-2026 |
Record with the slug company-party-2026 in the area with the slug prefix events |
/events |
Overview of the area with the slug prefix events – its overview template, without a record |
/1758 |
Record number 1758 |
/en/summer-party-2026 |
English language version |
/company-party-2026,print |
Record with the template print – only if it is released as a URL override for the area |
/0,search |
Template search without a record – only if it is marked as “Standalone” |
Pure digit sequences are always record numbers; anything else is a slug. If the first part of the address is the slug prefix of a content area, the rest is searched only in this area; otherwise the whole path is searched in the areas without a prefix. The language code is part of the address as soon as the instance has more than one language. Generate addresses with CMS:URL – never by hand, then language, slug and domain always match.
If the address consists only of the language code (/en, without a trailing slash),
camindo CMS redirects to the start page of the language (/en/ or /en_US/ –
depending on how it is set up). If the site has only one language and a record has exactly
this slug, the record is shown instead. Addresses for which there is nothing – unknown record,
unknown language, template not released – respond with the status 404.
Syntax of CMS commands
(CMS:COMMAND value parameter=value parameter[key]="value" …)
- Command names and parameter names are not case-sensitive.
- Parameters are separated by spaces; a command may span several lines.
- The order in which a command processes its parameters is determined by the command itself, not by the order in which the parameters are written.
- The first value without
=is the main parameter of the command:(CMS:TEXT title)is the same as(CMS:TEXT field=title). - Values that contain spaces are enclosed in quotation marks, single or double:
(CMS:TEXT field="title")is the same as(CMS:TEXT field='title'). - Subkeys in square brackets:
filter[area]=news. Empty brackets append to a list:sort[]=date sort[]=title. If the same subkey is given several times, a list is created as well (in contrast to PHP array syntax,[]is optional). - Instead of
=, a comparison operator may stand between parameter and value –==,!=,!,!!,<,>,<=,>=,<<,>>:filter[price] >= 100is the same asfilter[price]=">=100". The value then applies unchanged; an operator at its beginning is no longer evaluated:filter[code] < ">2"compares with the text “>2”. With=it stays as before –filter[code] = ">2"means “greater than 2” (see Filter syntax). - The line break after a CMS command is swallowed, in the same way as after a
<?php … ?>block. - Command parameters in double quotation marks may contain PHP variables:
attr[alt]="{$myvar}"(parameters without quotation marks are handled internally like parameters in double quotation marks).
Errors – an unknown command, an unknown parameter, a missing bracket – do not prevent saving. The detail view of the template shows them with the line number. They also appear on the start page in the widget “Template messages” – in case templates are edited through the file system.
Combining CMS commands and PHP code
$set <?php … ?> blocks may appear freely in the template, but CMS commands inside
PHP blocks lead to errors! To use the contents of CMS commands in PHP blocks, use the
parameter set (for example set = myvar) and read the content in the PHP block from $set
($set['myvar']).
$cms The contents of the requested record are in $cms; so are the contents of records
found with CMS:SELECT.
For output, always use the CMS output commands (such as
CMS:TEXT). These commands take care of correct formatting; a<?php echo $cms[…] ?>does none of this. Use$cmsonly where a command needs a value as a parameter, for examplefilter[category]="{$cms['record_id']}".
Common parameters of the output commands
| Parameter | Effect |
|---|---|
ifempty |
replacement text if the value is empty |
instead |
replacement text if the value is not empty |
prefix, suffix |
text before and after – only if the value is not empty |
set |
do not output the value, but store it in $set[…] |
id |
read from another record (number or slug), for example (CMS:TEXT title id=imprint) – only published records, as in the URL |
replace |
replace partial texts (replace="old?new") – for TEXT, NUMBER, DATE, DATETIME, TIME |
(CMS:TEXT subtitle prefix="<h2>" suffix="</h2>")
(CMS:NUMBER price format="%.2f" suffix=" $" ifempty="on request")
Values in attributes and JavaScript
camindo CMS removes script, event handlers (onclick …), iframe, object, embed and
javascript: links from all record contents – when saving and again when outputting.
HTML formatting remains, which is why CMS:TEXT outputs contents unescaped.
If you insert a value into an HTML attribute, escape it with specialchars=true –
otherwise a quotation mark in the content would end the attribute:
<img src="…" alt="(CMS:TEXT title specialchars=true)">
<a href="(CMS:URL)" title="(CMS:TEXT teaser specialchars=true striptags=true lbrconvert=false)">…</a>
The same applies to form input that you put back into the field after submitting:
<input name="data[nickname]" value="(CMS:TEXT useval="{$_POST['data']['nickname']}" specialchars=true)">
Commands that escape themselves: Some commands already escape their output with
htmlspecialchars. For them, specialchars is omitted – an already escaped value would be
escaped a second time, and an & would appear as &:
| Command | Escapes |
|---|---|
| CMS:MEDIA | all attributes of the <img> tag: src, alt, width, height and attr[…] – not the individual values via subfield |
| CMS:USER_TOKEN | the value of the hidden field |
| CMS:USER_PASSWORD_RULES | the entire output (JSON for an attribute) |
| CMS:XML | version and encoding |
| CMS:JUMP_LIST, CMS:JUMP_PREV, CMS:JUMP_NEXT | the URL in {href} |
What you pass to one of these commands must therefore be unescaped text – without HTML tags and entities:
(CMS:TEXT title striptags=true ampconvert=false lbrconvert=false set=title)
(CMS:MEDIA image attr[alt]="{$set['title']}")
Never escaped are prefix, suffix, ifempty and instead – they come from the
template and may contain HTML. Only CMS:TEXT and CMS:SNIPPET know
specialchars. Values of other commands, such as CMS:MEDIA … subfield=filename,
CMS:RELATION or CMS:RAW, store them with set for an attribute
and output them escaped via CMS:TEXT:
(CMS:MEDIA document subfield=filename set=filename)
<a href="(CMS:MEDIA document subfield=path)" title="(CMS:TEXT useval="{$set['filename']}" specialchars=true lbrconvert=false)">…</a>
CMS:URL and CMS:MEDIA … subfield=path produce only URL-safe characters, and so does
CMS:TEXT … urlencode=true – they can stand in href and src without escaping.
If JavaScript needs a value, output it as a data- attribute and read it there – so
that no content ever appears in the script code itself:
<div id="product" data-title="(CMS:TEXT title specialchars=true)"></div>
<script>var title = document.getElementById('product').dataset.title;</script>
Lists, SELECT and the result loop
Lists always consist of two templates: the calling template queries with CMS:SELECT and names a list template. The list template runs through the hits between CMS:RESULT_START and CMS:RESULT_END.
Calling template:
<h2>Latest news</h2>
(CMS:SELECT news_list
filter[area]=news
sort=date
order=desc
limit=10
noresult="<p>No news.</p>"
)
List template news_list:
<ul>
(CMS:RESULT_START)
<li><a href="(CMS:URL)">(CMS:TEXT title)</a> – (CMS:DATE date)</li>
(CMS:RESULT_END)
</ul>
Inside the loop, the current row is the running record. A list template may itself contain
CMS:SELECT – for example the tags of each article.
Paging: count=true counts all hits; CMS:JUMP_LIST,
CMS:JUMP_PREV and CMS:JUMP_NEXT turn this into the navigation.
Publication: Templates find only published records – released, publication date
reached, expiry date not yet reached. The parameters future and archive relax this per
query, for example for an archive or a preview of upcoming events.
The parameter
stateis intended exclusively for views inside the back end.
Addressing areas
Content areas are addressed by their name or by their number (area_id):
filter[area]=news, filter[area]=12 or filter[area_id]=12. Separate several areas with
commas. Because field names are uniform across the system, several areas can be filtered and
sorted together:
(CMS:SELECT timeline
filter[area]=news,events,blog
filter[date] >= 2026-01-01
sort=date
order=desc
)
sort=area sorts by area; _default stands in filters and sorting for the main field of each
area, whatever it is called there.
Overview page of an area
The address consisting only of the slug prefix (/events) calls the overview template
of the area – without a record. In its place stand the details of the area:
| Value | Contents |
|---|---|
area_id |
number of the area |
area |
name of the area |
area_label |
label of the area (snippets in the language of the call) |
area_type, area_icon |
type and icon of the area |
instance_id, language_id |
instance of the area and language of the call |
slug_prefix |
slug prefix in the language of the call |
record_id |
always 0 – there is no current record |
overview |
true |
In this way, an overview template lists the records of its area without having their name written in it – and can be used for several areas:
<h1>(CMS:TEXT area_label specialchars=true)</h1>
(CMS:SELECT event_list
filter[area]="{$cms['area_id']}"
sort=date order=asc
)
Linked records
With relation, CMS:SELECT retrieves the records that are linked with the current record
(or with the record named in src_id) via a link field.
(CMS:SELECT gallery_list
relation=see_also
sort="created_at desc"
)
With sort="_custom asc", linked records appear in the order that the editorial team set
in the lookup field (using the arrow buttons):
(CMS:SELECT gallery_list
relation=images
sort="_custom asc"
)
To sort and filter by fields of linked records, use dot notation: sort=author.last_name.
If there is no current record and src_id is not given or invalid, the search with the
parameter relation finds nothing.
The concept: every record has its own page
In camindo CMS, every record has its own page in the frontend – not only articles and
products, but also every category, every tag, every material. A website therefore needs
no parameters such as ?cat=12 or ?tag=oak:
- Linking: Lists link every record with CMS:URL – the category just as the product.
- Default template per area: Each content area gets a default template – a category
page, a tag page, a product page. With a slug prefix, each kind of page gets its own address
space (
/en/category/living,/en/tag/oak). - Listing linked contents: On the page, the record is in
$cms; the template queries its linked records directly through it.
Category page (default template of the area categories):
<h1>(CMS:TEXT title)</h1>
(CMS:TEXT text prefix="<p>" suffix="</p>")
(CMS:SELECT product_list
relation=rel_products
sort=position
order=asc
)
List template product_list – the products link back to their own page:
<ul>
(CMS:RESULT_START)
<li><a href="(CMS:URL)">(CMS:TEXT title)</a></li>
(CMS:RESULT_END)
</ul>
Navigation is just as simple – a list of all categories with links:
(CMS:RESULT_START)
<a href="(CMS:URL)">(CMS:TEXT title)</a>
(CMS:RESULT_END)
Which query returns the linked records depends on from which page the link starts:
| Link | Query on the page of the record |
|---|---|
| starts from the record (its own link field) | (CMS:SELECT list relation=fieldname) |
relation field (relation) of another area points to it |
(CMS:SELECT list relation=rel_areaname) – via the counterpart field |
pointer field (pointer) of another area points to it |
(CMS:SELECT list filter[area]=areaname filter[fieldname]="{$cms['record_id']}") |
Relation fields (relation, relation1) are linked in both directions: when you create
them, camindo CMS creates in the target area a counterpart field named
rel_{areaname} (for example rel_products), which leads the link from the other side. The
category page therefore queries its products with relation=rel_products, and the product page
queries its categories with relation=categories.
Pointer fields (pointer, pointer1) are directed – they can point to several areas.
The target record does not know its pointers; the counterpart is a filter on the pointer field
with the number of the current record:
(CMS:SELECT product_list
filter[area]=products
filter[category]="{$cms['record_id']}"
)
Note: The number of the current record is in
$cms['record_id']– there is no keyid. An empty filter value cancels the filter:filter[category]="{$cms['id']}"therefore does not find no products, but all products.
Searches and free filter combinations remain parameters (?q=…), because they are not
records. Everything that is a record, on the other hand, has its own address – and with it
its page title, description and frontend editing like any other page.
Filter syntax
The values in filter[…] follow the same syntax as the search mask in the back end:
| Value | Meaning |
|---|---|
Berlin |
equal |
Berlin,Hamburg |
either of them (with filter_mode[field]=AND: both) |
Hen*, *man, *berg* |
wildcards |
!Berlin, !*man |
not equal |
==Star* |
exactly equal, * without wildcard effect |
>100, >=100, <100, <=100 |
comparisons – also for dates |
>2 AND <4, Hen* OR Her* |
combine conditions within the same field |
2026, 2026-06 |
date: the whole year or the whole month |
now, today, now-2h, today+7d |
date relative to the call, in the time zone of the user or the language, like a typed date; today without an hour offset is the whole day. Offset in s, m, h, d |
utc_now, utc_today, utc_now-15m |
the same in UTC – for comparisons with points in time that apply regardless of the visitor (blocking periods, time window of a scheduler run) |
(empty), !(empty) |
without its own value (empty or not translated) or with one |
The operator may also stand between key and value – handy when the value comes from a variable:
(CMS:SELECT embargo_mail
filter[area] = press_releases
filter[online_at] > {$set['last_run']}
filter[online_at] <= {$set['this_run']}
filter_mode[online_at] = AND
future = true
)
is the same as filter[online_at]=">{$set['last_run']}" and
filter[online_at]="<={$set['this_run']}". With such an operator, the value itself is free of
operators: if the content of the variable begins with <, > or !, it is compared as text.
A date value that cannot be evaluated (such as a Unix timestamp like
$_SERVER['REQUEST_TIME']) is skipped without a message – the list then shows all
records, as if the filter were not there. For analyses in your development environment,
debug=true helps: the generated SQL query is displayed.
Text comparisons by language: Text and character fields (including _default) are
compared and sorted according to the rules of the language version in which you search –
derived from the language code, nothing needs to be set. Always valid: no distinction between
upper and lower case and accents (cafe finds “Café”), ß equals ss. In addition, the rules
of individual languages apply:
| Language | Rule | Example |
|---|---|---|
| German | ä = ae, ö = oe, ü = ue (phone-book rule) |
Mue* finds “Müller”, Mul* does not |
| Swedish | å, ä, ö are letters of their own |
o does not find ö; ö sorts after z |
| Danish, Norwegian | æ, ø, å are letters of their own, aa = å |
Aa* finds “Ålesund” |
| Turkish | i/İ and ı/I are different |
Bulgarian, Esperanto, Estonian, Galician, Icelandic, Croatian and Bosnian, Latvian, Lithuanian, Polish, Romanian, Russian, Serbian (Latin), Slovak, Slovenian, Spanish, Czech, Hungarian and Vietnamese have rules of their own as well. All other languages – English included – follow the general rules.
This applies equally to =, wildcards and comparisons such as >, and to full-text fields
(Mueller finds “Müller”, Muller does not). In the back end, the language that is set for
the instance of the searched area applies; if the instances of one search follow different
rules, only the general rules apply.
Different filters are always combined with AND. Full-text fields understand +must -mustnot;
encrypted fields only the exact value.
In areas with a tree structure, filter[parent_id]=42 returns all descendants of record 42;
together with filter[depth]=1 only the direct children – the basis for navigations.
Including and organizing templates
CMS:INCLUDE includes another template – for example the header and footer of the page:
(CMS:INCLUDE layout.header)
<main>…</main>
(CMS:INCLUDE layout.footer)
Template names only have to be unique within their template area. If a name exists in several
areas, refer to it as areaname.templatename. A name that is not unique is reported by the page
as an error.
Typical division:
| Type | Task |
|---|---|
| Page templates | default templates of the content areas: output a record as a complete page |
| List templates | run through the hits of a CMS:SELECT |
| Building blocks | header, footer, navigation, teaser – included via CMS:INCLUDE |
| Standalone templates | pages without a record: search, sitemap, forms |
| Scheduled templates | run as a task of the scheduler, without visitors (see below) |
| List templates (back end) | design the record list of the editorial team (see below) |
Caching
Expensive building blocks – a navigation made of many queries, a product list – can be cached
by CMS:INCLUDE with caching: (CMS:INCLUDE layout.navigation caching=1h) creates the
navigation at most once an hour per website and language. What else the output depends on
belongs in cacheparams – see CMS:INCLUDE.
Multilingualism
A template automatically outputs the language version of the call – if a translation is
missing, the primary language. Links with CMS:URL keep the language; with the parameter lang,
you link explicitly to another language version. To build a language switcher, use
CMS:SELECT_LANGVERSION.
Date and number formats follow the language version; so does the time zone.
Fixed labels (“Read more”, “Search”) are maintained by the editorial team as separate records
(for example in an area named “Other texts”) in all frontend languages. This area should have a
single-language slug field and a multilingual text field. Then CMS:TEXT works as a
snippet provider; see CMS:TEXT with the id parameter.
<a href="(CMS:URL)">(CMS:TEXT id=labels.readmore)</a>
Frontend editing
CMS:FED places an edit marker on a content that only users with frontend editing switched on can see. A click opens the input form of the record in the back end. CMS:FED_CODE outputs HTML only in this case – for example a stylesheet for the markers.
Security headers: CSS, JavaScript, JSON and embedding
camindo CMS sends a few HTTP headers before each template, which the browser applies as protection:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
Referrer-Policy: strict-origin-when-cross-origin
For ordinary HTML pages you do not need to do anything. Two cases do have an effect – and the cause is not obvious at first glance:
Templates that deliver CSS, JavaScript or JSON
A template is delivered as text/html unless it says otherwise. Because of nosniff, the
browser then does not accept the content as a stylesheet or script, even if it is correct. The
page looks unstyled, the script does not run – and the browser console reports something like
“refused to execute script … MIME type ('text/html') is not executable” or
“… not a supported stylesheet MIME type”.
Therefore set the appropriate type at the beginning of each such template – before any output, without a blank line in front:
(CMS:HEADER "Content-Type: text/css; charset=utf-8")
(CMS:HEADER "Content-Type: text/javascript; charset=utf-8")
(CMS:HEADER "Content-Type: application/json; charset=utf-8")
Embedding output in other websites
A page of the website can only be shown in an <iframe> within the same website. This
protects sign-in, profile and shopping cart from a foreign page overlaying them invisibly and
slipping clicks to visitors (clickjacking). If a partner website embeds a page via <iframe>,
the frame therefore stays empty or shows an error page of the browser; the console reports
“Refused to frame … because an ancestor violates … frame-ancestors” or
“… X-Frame-Options to 'sameorigin'”.
If a template should be allowed to be embedded – a widget, an event calendar for partners –
release it only in this template: remove X-Frame-Options by giving it no value, and name
the permitted websites in frame-ancestors:
(CMS:HEADER "X-Frame-Options:")
(CMS:HEADER "Content-Security-Policy: frame-ancestors 'self' https://www.partner.example https://*.partner.example")
Both lines belong together: a browser that still sees X-Frame-Options refuses the frame despite
frame-ancestors. Name the partners individually – frame-ancestors * allows every website to
embed the page – including the pages with forms. Other kinds of embedding are not affected:
images, downloads, data loaded by JavaScript (JSON) and links to your website work without
release.
More on the header lines: CMS:HEADER.
Templates in the file system
Each template is stored as a file at
var/templates/{instance_id}/{area_name}/{template_name}.cms.php. You can edit it there with a
code editor and manage it with Git. camindo CMS applies changes every minute (or immediately via
“Refresh” in the module Templates) and recompiles the template. Rename templates in the back end –
in the file system, a rename counts as deleting and creating anew, and the assignments are lost.
Error messages appear in the widget “Template messages”.
Editor support
For Visual Studio Code there is the extension “camindo CMS Templates”
(github.com/dirkintelligence/camindo-vscode,
also in the Visual Studio Marketplace and on Open VSX). In *.cms.php files it colors the
CMS commands purple and the PHP expressions inside them orange – like the view in the
backend –, completes the command names after (CMS: and then the parameters of the command,
and shows their description on hover, in the same language as the backend.
The extension knows the commands of the core and the standard modules. If your modules bring their own commands, write the command list of your project with
php Core/cron/camindo.php template commands --all --out=var/vscode
and enter the directory in the VS Code setting camindo.commandsFile.
Scheduled templates
A template with the usage “Scheduled (scheduler)” (tab “Access” of the template) does not run as a page, but as a task of the scheduler (task type “Template”; see Handbook for administrators). It can never be reached via an address.
It runs like a page call without a record: in the website of its template area, in the language that the task names – with its texts, snippets, date formats and time zone. There is no visitor, hence no sign-in and no shopping cart. The task passes the time window since the last successful run:
| Variable | Contents |
|---|---|
$set['last_run'] |
start of the time window (exclusive), time zone of the language |
$set['this_run'] |
end of the time window (inclusive), time zone of the language |
$set['last_run_utc'], $set['this_run_utc'] |
the same in UTC |
On the first run, the time window is empty. If a run fails, the next one catches up on the whole
period – nothing is lost and nothing is processed twice. The time window fits directly into a
filter; two conditions for the same field are combined with AND via filter_mode:
(CMS:SELECT embargo_mail
filter[area]=press_releases
filter[online_at] > {$set['last_run']}
filter[online_at] <= {$set['this_run']}
filter_mode[online_at]=AND
future=true
)
The result template then sends, for example with CMS:EMAIL (submit=1), one message
per hit; CMS:URL with absolute=true returns the complete address for links. If a command
reports an error, the run counts as failed. On request, the task saves what the template outputs
under var/custom/ – this is how a sitemap or a feed is created as a file.
Preview
The preview from the back end also shows unpublished records with their template. Lists in it
still show only published records, as do id=… and CMS:RELATION. In the preview,
$this->isPreview() returns true.
List templates for the back end
A content area can have a list template that is used only in the back end: for an additional
column of the record list and for the detailed preview in lookup fields. In the template, the
record of the row is available as $cms; the output commands CMS:TEXT, CMS:MEDIA … work as in
the frontend:
(CMS:MEDIA image_small width=60)
<small>(CMS:NUMBER price format="%.2f" suffix=" $")</small>
CMS:RELATION is helpful here to list the main field of linked records.
As of camindo CMS 9.1.3