CP Templates, Validation, Translations, and File Headers
CP Twig Templates
Indent with 4 spaces. Spaces inside delimiters: {{ value }}, {% tag %}, {# comment #}.
File Naming
_underscoreprefix = partial (not directly accessible via URL). Use for includes, layouts, and components.- No underscore = route entry point or publicly accessible template.
- Lowercase with hyphens for directories:
plugin-store/,entry-types/. - camelCase for form macro names:
textField,lightswitchField.
Template Directory Structure
templates/
├── _components/ # Reusable UI components (sidebars, cards)
├── _includes/ # Shared partials (forms, pagination)
├── _layouts/ # Base layouts for CP pages
├── settings/ # Settings pages (route-accessible)
│ ├── _edit.twig # Edit partial
│ └── index.twig # Index entry pointControl Structures
{% if condition %}
Something
{% endif %}
{% for item in items %}
{{ item.title }}
{% endfor %}Form Macros
Import Craft's form helpers and use the object syntax:
{% import '_includes/forms.twig' as forms %}
{{ forms.textField({
label: "Name"|t('app'),
id: 'name',
name: 'name',
value: entity.name,
errors: entity.getErrors('name'),
required: true,
}) }}Defaults and Null Coalescing
Use ?? for safe defaults in templates:
{% set readOnly = readOnly ?? false %}
{% set title = title ?? 'Untitled'|t('app') %}Output Safety
Never use |raw on user-provided or admin-provided content rendered inside <style> or <script> tags — even admin-entered values are XSS vectors if an admin account is compromised. For CSS values, sanitize or whitelist. For HTML content, use |purify (Craft's HTML Purifier filter). Reserve |raw for trusted, hardcoded content or content that has already been sanitized.
Whitespace Control
Use {%- and -%} to trim surrounding whitespace in low-level components:
{%- set class = class ?? 'default' -%}Validation
Use defineRules() with array notation:
protected function defineRules(): array
{
$rules = parent::defineRules();
$rules[] = [['name', 'handle'], 'required'];
$rules[] = [['handle'], UniqueValidator::class, 'targetClass' => MyEntityRecord::class];
$rules[] = [['batchSize'], 'integer', 'min' => 1, 'max' => 500];
$rules[] = [['apiUrl'], 'url'];
return $rules;
}Always call parent::defineRules() first to inherit base validation. Use Craft's built-in validators (HandleValidator, UniqueValidator, DateTimeValidator) before writing custom ones.
Inline Validators
Use the string method name form for inline validators. Do not use [$this, 'method'] callable arrays or inline closures:
// Correct — matches craft\models\Section, craft\models\EntryType
$rules[] = [['siteSettings'], 'validateSiteSettings'];
$rules[] = [['previewTargets'], 'validatePreviewTargets'];
// Wrong — Craft core does not use this form
$rules[] = [['siteSettings'], [$this, '_validateSiteSettings']];
// Wrong — inline closures make rules unreadable, prevent reuse
$rules[] = [['siteSettings'], function ($attribute) { ... }];The validator method is public, no underscore prefix. Yii's validator dispatcher invokes it by name on the model instance, making it part of the public API surface:
public function validateSiteSettings(): void
{
if (empty($this->siteSettings)) {
$this->addError('siteSettings', Craft::t('my-plugin', 'At least one site is required.'));
}
}The when callable in validator rules follows the same pattern — public method, no underscore:
$rules[] = [['maxRows'], 'integer', 'min' => 1, 'when' => [$this, 'hasMaxRows']];
public function hasMaxRows(): bool
{
return $this->maxRows !== null;
}Translations
Always use the plugin handle as the translation category:
Craft::t('pluginhandle', 'Some translatable text'){{ 'Some translatable text'|t('pluginhandle') }}In CP templates, translate labels inline with the |t() filter:
{{ forms.textField({
label: "Field Label"|t('pluginhandle'),
instructions: "Help text for this field."|t('pluginhandle'),
}) }}Never hardcode user-facing strings. All CP labels, messages, and descriptions must go through Craft::t() or the |t() Twig filter.
File Header
Every PHP file starts with:
<?php
/**
* <Plugin Name> plugin for Craft CMS
*
* <Plugin description>.
*
* @link <Author URL>
* @copyright Copyright (c) <Year> <Author Name>
*/
namespace vendor\pluginhandle\path\to;