Common utilities and building blocks for LindemannRock Craft CMS plugins.
This package provides shared functionality for all LindemannRock plugins:
- Edition Support for Craft Plugin Store licensing with standardized tiers (Standard/Lite/Pro)
- Traits for Settings models (displayName, database persistence, config overrides)
- Device Detection for standardized UA parsing and caching (Matomo DeviceDetector)
- DateTimeHelper for centralized date/time formatting with timezone support
- DateRangeHelper for standardized date range selection and filtering
- Twig Extensions for plugin name helpers and datetime filters in templates
- Helpers for common plugin initialization tasks and geographic utilities
- Templates for shared components (plugin-credit, info-box, ip-salt-error)
- GeoHelper for ISO 3166-1 country code lookups (249 countries)
- Craft CMS 5.0+
- PHP 8.2+
cd /path/to/project
composer require lindemannrock/craft-plugin-basecd /path/to/project
ddev composer require lindemannrock/craft-plugin-baseuse lindemannrock\base\traits\EditionTrait;
class MyPlugin extends Plugin
{
use EditionTrait;
// Define your tier model (override default)
public static function editions(): array
{
return [
self::EDITION_LITE, // Entry paid tier
self::EDITION_PRO, // Full featured tier
];
}
}Available tier models:
| Model | Editions | Use Case |
|---|---|---|
| Free-only | [STANDARD] |
Free plugins (default) |
| Two paid | [LITE, PRO] |
Commercial plugins |
| Free + paid | [STANDARD, PRO] |
Freemium plugins |
| Three tiers | [STANDARD, LITE, PRO] |
Complex offerings |
Checking editions:
// In controllers - gate entire actions
public function actionCloudBackup(): Response
{
MyPlugin::getInstance()->requireEdition(MyPlugin::EDITION_PRO);
// ... pro-only code
}
// In services - conditional logic
if (MyPlugin::getInstance()->isPro()) {
// Pro feature
}
if (MyPlugin::getInstance()->isAtLeast(MyPlugin::EDITION_LITE)) {
// Lite or Pro feature
}In templates:
{% set plugin = craft.app.plugins.getPlugin('my-plugin') %}
{% if plugin.isPro() %}
{# Pro-only UI #}
{% else %}
<a href="#">Upgrade to Pro</a>
{% endif %}
{# Show current edition #}
<span class="edition-badge">{{ plugin.getEditionName() }}</span>use lindemannrock\base\traits\SettingsConfigTrait;
use lindemannrock\base\traits\SettingsDisplayNameTrait;
use lindemannrock\base\traits\SettingsPersistenceTrait;
class Settings extends Model
{
use SettingsDisplayNameTrait;
use SettingsPersistenceTrait;
use SettingsConfigTrait;
public string $pluginName = 'My Plugin';
protected static function tableName(): string
{
return 'myplugin_settings';
}
protected static function pluginHandle(): string
{
return 'my-plugin';
}
// Optional: specify field types for database persistence
protected static function booleanFields(): array
{
return ['enableFeature', 'debugMode'];
}
protected static function integerFields(): array
{
return ['cacheTimeout', 'maxItems'];
}
protected static function jsonFields(): array
{
return ['excludePatterns', 'customSettings'];
}
}use lindemannrock\base\helpers\PluginHelper;
public function init(): void
{
parent::init();
// Bootstrap base module (registers Twig extension, logging, translations)
PluginHelper::bootstrap($this, 'myPluginHelper', ['myPlugin:viewLogs']);
// Apply plugin name from config file
PluginHelper::applyPluginNameFromConfig($this);
}{# Plugin name helpers (via Twig extension) #}
{{ myPluginHelper.displayName }} {# "My Plugin" #}
{{ myPluginHelper.fullName }} {# "My Plugin Manager" #}
{{ myPluginHelper.pluralDisplayName }} {# "My Plugins" #}
{{ myPluginHelper.lowerDisplayName }} {# "my plugin" #}
{# Shared components #}
{% include 'lindemannrock-base/_components/plugin-credit' %}
{% include 'lindemannrock-base/_components/info-box' with {
message: 'This is an informational message',
type: 'info' {# 'info', 'success', 'warning' #}
} %}
{% include 'lindemannrock-base/_components/ip-salt-error' with {
pluginHandle: 'my-plugin',
envVarName: 'MY_PLUGIN_IP_SALT'
} %}| Trait | Methods Provided |
|---|---|
EditionTrait |
editions(), isStandard(), isLite(), isPro(), isAtLeast(), isBelow(), requireEdition(), getEditionName(), hasMultipleEditions() |
SettingsDisplayNameTrait |
getDisplayName(), getFullName(), getPluralDisplayName(), getLowerDisplayName(), getPluralLowerDisplayName() |
SettingsPersistenceTrait |
loadFromDatabase(), saveToDatabase() |
SettingsConfigTrait |
isOverriddenByConfig() |
DeviceDetectionTrait |
detectDeviceInfo(), detectLanguageFromConfig(), buildDeviceModel() |
| Template | Purpose |
|---|---|
plugin-credit |
Footer credit with plugin name and developer link |
info-box |
Styled info/success/warning message box |
ip-salt-error |
Error banner for missing IP hash salt configuration |
badge |
Colored status badge with dot and text |
cards/unified-card |
Flexible card for dashboards, analytics, and utilities |
row-actions |
Action buttons/menus for table rows |
filter-status |
Status dropdown filter with colored indicators |
filter-dropdown |
Simple dropdown filter |
filter-daterange |
Date range picker filter |
export-menu |
Export dropdown with format checking (Excel/CSV/JSON) |
cp-table (layout) |
Reusable table/listing page layout |
cp-analytics (layout) |
Reusable analytics/dashboard page layout |
| Helper | Purpose |
|---|---|
PluginHelper::bootstrap() |
Registers base module, Twig extension, and logging |
PluginHelper::applyPluginNameFromConfig() |
Applies custom plugin name from config file |
PluginHelper::registerTranslations() |
Register translation messages for a plugin |
PluginHelper::getCacheBasePath() |
Get the cache base path for a plugin |
PluginHelper::getCachePath() |
Get a specific cache type path for a plugin |
PluginHelper::getCacheKeyPrefix() |
Build standardized cache key prefixes for Redis/file caches |
PluginHelper::getCacheKeySet() |
Build standardized Redis set keys for cache tracking |
PluginHelper::isPluginEnabled() |
Check if a plugin is installed and enabled |
PluginHelper::isPluginInstalled() |
Check if a plugin is installed (may not be enabled) |
PluginHelper::getPlugin() |
Get a plugin instance (null if not available) |
CpNavHelper::buildSubnav() |
Build CP subnav from section definitions |
CpNavHelper::firstAccessibleRoute() |
Get first accessible CP route from section definitions |
GeoHelper::getCountryName() |
Convert ISO 3166-1 alpha-2 country code to name |
GeoHelper::getAllCountries() |
Get all 249 countries as code => name array |
GeoHelper::isValidCountryCode() |
Validate a country code |
DateTimeHelper::formatDatetime() |
Format datetime for display with timezone |
DateTimeHelper::formatDate() |
Format date only for display |
DateTimeHelper::formatTime() |
Format time only for display |
DateTimeHelper::forDatabase() |
Format for MySQL datetime storage |
DateTimeHelper::forApi() |
Format as ISO 8601 for APIs |
DateTimeHelper::forFilename() |
Format safe for filenames |
DateRangeHelper::getDefaultDateRange() |
Get default date range from config |
DateRangeHelper::getOptions() |
Get standard date range options for dropdowns |
DateRangeHelper::getBounds() |
Get UTC date bounds for a range |
DateRangeHelper::applyToQuery() |
Apply date range filter to a query |
DateRangeHelper::getDaysCount() |
Get number of days in a range |
ExportHelper::isFormatEnabled() |
Check if export format is enabled |
ExportHelper::getEnabledFormats() |
Get all enabled export formats |
ExportHelper::toCsv() |
Export data to CSV |
ExportHelper::toJson() |
Export data to JSON |
ExportHelper::toExcel() |
Export data to Excel |
CsvImportHelper::parseUpload() |
Parse CSV file upload with validation and delimiter detection |
ColorHelper::getPaletteColor() |
Get a color from the palette by name |
ColorHelper::getPaletteColorNames() |
Get all available palette color names |
The cp-table layout supports row highlighting via CSS classes.
- Provide a
rowClassKeyintableConfig.table:
{% set tableConfig = {
table: {
rowClassKey: 'rowClass',
items: items,
columns: [...]
}
} %}- Set
rowClasson each item (server-side):
lr-row--infolr-row--successlr-row--warninglr-row--danger
These tones include hover/selected overrides that integrate with Craft’s table styles.
| ColorHelper::getColorSet() | Get entire color set by name |
| ColorHelper::getSetColor() | Get specific color from a set |
| ColorHelper::getNeutralColor() | Get neutral/unselected color |
| ColorHelper::getDefaultColor() | Get default fallback color |
| ColorHelper::getFilterColor() | Get color for filter display |
| ColorHelper::hasColorSet() | Check if a color set exists |
| ColorHelper::getAvailableColorSets() | Get all available color set names |
| ColorHelper::registerColorSet() | Register custom color set at runtime |
Use CpNavHelper to centralize permission + settings checks for CP subnav and default route redirects.
use lindemannrock\base\helpers\CpNavHelper;
$sections = $this->getCpSections($settings);
$item['subnav'] = CpNavHelper::buildSubnav($user, $settings, $sections);$sections = MyPlugin::$plugin->getCpSections($settings, false);
$route = CpNavHelper::firstAccessibleRoute($user, $settings, $sections);
if ($route) {
return $this->redirect($route);
}Section options:
permissionsAll: require all permissions (array or string)permissionsAny: require any permission (array or string)settingsFlag: require a truthy setting on the settings model (string)when: custom callbackfn($settings, $user) => boolfor complex rules
Provides consistent cache directory structure across plugins: storage/runtime/{plugin-handle}/cache/{type}/
use lindemannrock\base\helpers\PluginHelper;
// Get the base cache path for a plugin
$basePath = PluginHelper::getCacheBasePath($plugin);
// Returns: storage/runtime/my-plugin/cache/
// Get a specific cache type path
$searchCache = PluginHelper::getCachePath($plugin, 'search');
// Returns: storage/runtime/my-plugin/cache/search/
$autocompleteCache = PluginHelper::getCachePath($plugin, 'autocomplete');
// Returns: storage/runtime/my-plugin/cache/autocomplete/
$deviceCache = PluginHelper::getCachePath($plugin, 'device');
// Returns: storage/runtime/my-plugin/cache/device/Standardized cache key prefixes and Redis set keys for tracking cached entries across plugins.
use lindemannrock\base\helpers\PluginHelper;
$prefix = PluginHelper::getCacheKeyPrefix($plugin->id, 'device');
// Returns: my-plugin:device:
$setKey = PluginHelper::getCacheKeySet($plugin->id, 'device');
// Returns: my-plugin:device:keysCheck if other plugins are installed/enabled before using their APIs:
use lindemannrock\base\helpers\PluginHelper;
// Check if a plugin is installed AND enabled (most common)
if (PluginHelper::isPluginEnabled('redirect-manager')) {
// Safe to use Redirect Manager's API
}
// Check if installed (regardless of enabled state)
if (PluginHelper::isPluginInstalled('formie')) {
// Plugin files exist
}
// Get the plugin instance to access its services/settings
$formie = PluginHelper::getPlugin('formie');
if ($formie !== null) {
$settings = $formie->getSettings();
}
// Get the plugin's display name (respects custom pluginName setting)
$name = PluginHelper::getPluginName('redirect-manager'); // "Redirect Manager" or custom name
$name = PluginHelper::getPluginName('missing-plugin', 'Fallback Name'); // "Fallback Name"| Method | Returns | Use Case |
|---|---|---|
isPluginEnabled($handle) |
bool |
Check before using plugin's API |
isPluginInstalled($handle) |
bool |
Check if files exist (may be disabled) |
getPlugin($handle) |
?PluginInterface |
Access plugin services/settings |
getPluginName($handle, $fallback) |
string |
Get display name (respects custom names) |
{# Check if plugin is enabled #}
{% if lrPluginEnabled('formie') %}
<p>Formie integration available</p>
{% endif %}
{# Get plugin display name (respects custom pluginName setting) #}
{{ lrPluginName('redirect-manager') }}
{# With fallback for dynamic/unknown plugins #}
{{ lrPluginName(item.sourcePlugin, item.sourcePlugin|replace({'-': ' '})|title) }}| Function | Returns | Use Case |
|---|---|---|
lrPluginEnabled(handle) |
bool |
Conditional plugin features |
lrPluginName(handle, fallback) |
string |
Display plugin names in UI |
use lindemannrock\base\helpers\GeoHelper;
// Get country name from code
$name = GeoHelper::getCountryName('US'); // "United States"
$name = GeoHelper::getCountryName('GB'); // "United Kingdom"
$name = GeoHelper::getCountryName('XX'); // "XX" (returns code if unknown)
// Get all countries
$countries = GeoHelper::getAllCountries(); // ['AD' => 'Andorra', 'AE' => 'United Arab Emirates', ...]
// Validate country code
$valid = GeoHelper::isValidCountryCode('US'); // true
$valid = GeoHelper::isValidCountryCode('XX'); // false
// Get dial code for a country
$dialCode = GeoHelper::getDialCode('US'); // "+1"
// Get dial code options for phone fields
$options = GeoHelper::getCountryDialCodeOptions(); // [{value: 'US', label: 'United States (+1)'}, ...]{# Get all countries for a select field #}
{% for code, name in lrCountries() %}
<option value="{{ code }}">{{ name }}</option>
{% endfor %}
{# Get country name by code #}
{{ lrCountryName('US') }} {# United States #}
{# Get dial code options for phone fields #}
{% for option in lrDialCodes() %}
<option value="{{ option.value }}">{{ option.label }}</option>
{% endfor %}
{# Get dial code for a country #}
{{ lrDialCode('US') }} {# +1 #}| Function | Returns | Use Case |
|---|---|---|
lrCountries() |
array |
All country codes → names for select fields |
lrCountryName(code) |
string |
Get country name by ISO code |
lrDialCodes() |
array |
Dial code options for phone select fields |
lrDialCode(code) |
string |
Get dial code for a country (e.g., +1) |
Provides centralized date/time formatting for all plugins. Respects Craft's timezone and configurable format preferences.
Create config/lindemannrock-base.php to set your preferences:
<?php
return [
// Time format: '12' (AM/PM) or '24' (military)
'timeFormat' => '24',
// Month format: 'numeric' (01), 'short' (Jan), 'long' (January)
'monthFormat' => 'numeric',
// Date order: 'dmy', 'mdy', 'ymd'
'dateOrder' => 'dmy',
// Date separator: '/', '-', '.' (only used with numeric month format)
'dateSeparator' => '/',
// Show seconds by default: true/false
'showSeconds' => false,
// Export formats (defaults: csv=true, json=false, excel=true)
'exports' => [
'csv' => true,
'json' => false,
'excel' => true,
],
// Default date range for analytics, logs, dashboards, and any date-filtered pages
// Options: today, yesterday, last7days, last30days, last90days, thisMonth, lastMonth, thisYear, lastYear, all
'defaultDateRange' => 'last30days',
// Environment-specific overrides
// 'production' => [
// 'timeFormat' => '12',
// 'monthFormat' => 'short',
// ],
];Plugins can override base settings in their own config file (e.g., config/sms-manager.php):
<?php
// config/sms-manager.php
return [
// ... other plugin settings ...
// Override default date range for this plugin only
// Used by analytics, logs, dashboard - any page with date filtering
'defaultDateRange' => 'last7days', // SMS is time-sensitive
// Override export formats for this plugin only
'exports' => [
'json' => true, // Enable JSON export for this plugin
],
];Resolution Order:
- Plugin config (
config/my-plugin.php) - highest priority - Base config (
config/lindemannrock-base.php) - Built-in defaults - lowest priority
use lindemannrock\base\helpers\DateTimeHelper;
// Display formatting (respects config + Craft timezone)
DateTimeHelper::formatDatetime($date); // "22/01/2026 15:45"
DateTimeHelper::formatDatetime($date, 'long'); // "22 January 2026 at 15:45"
DateTimeHelper::formatDatetime($date, showSeconds: true); // "22/01/2026 15:45:32"
// Compact datetime (no year) - ideal for dashboards
DateTimeHelper::formatCompactDatetime($date); // "22 Jan 15:45"
// Exclude year with includeYear parameter
DateTimeHelper::formatDatetime($date, includeYear: false); // "22/01 15:45"
DateTimeHelper::formatDate($date, includeYear: false); // "22/01"
DateTimeHelper::formatDate($date); // "22/01/2026"
DateTimeHelper::formatDate($date, 'medium'); // "22 Jan 2026"
DateTimeHelper::formatDate($date, 'long'); // "22 January 2026"
DateTimeHelper::formatTime($date); // "15:45" or "3:45 PM"
DateTimeHelper::formatTime($date, showSeconds: true); // "15:45:32"
DateTimeHelper::formatShortDate($date); // "Jan 22" (for charts)
DateTimeHelper::formatRelative($date); // "2 hours ago"
// Database formatting
DateTimeHelper::forDatabase($date); // "2026-01-22 15:45:32"
DateTimeHelper::forDatabaseDate($date); // "2026-01-22"
DateTimeHelper::forDatabaseDayStart($date); // "2026-01-22 00:00:00"
DateTimeHelper::forDatabaseDayEnd($date); // "2026-01-22 23:59:59"
// API formatting (ISO 8601)
DateTimeHelper::forApi($date); // "2026-01-22T15:45:32+00:00"
// Filename formatting
DateTimeHelper::forFilename(); // "2026-01-22-154532"
DateTimeHelper::forFilename($date, includeTime: false); // "2026-01-22"
// Utilities
DateTimeHelper::now(); // Current DateTime in Craft timezone
DateTimeHelper::isToday($date); // true/false
DateTimeHelper::isPast($date); // true/false
DateTimeHelper::isFuture($date); // true/false
DateTimeHelper::toCraftTimezone($date); // Convert UTC to Craft timezoneAll filters automatically respect the config settings:
{# Display formatting #}
{{ entry.dateCreated|lrDatetime }} {# 22/01/2026 15:45 #}
{{ entry.dateCreated|lrDatetime('long') }} {# 22 January 2026 at 15:45 #}
{{ entry.dateCreated|lrDatetime('short', true) }} {# 22/01/2026 15:45:32 (with seconds) #}
{{ entry.dateCreated|lrDate }} {# 22/01/2026 #}
{{ entry.dateCreated|lrDate('long') }} {# 22 January 2026 #}
{{ entry.dateCreated|lrTime }} {# 15:45 #}
{{ entry.dateCreated|lrTime('short', true) }} {# 15:45:32 (with seconds) #}
{{ entry.dateCreated|lrShortDate }} {# Jan 22 #}
{{ entry.dateCreated|lrRelative }} {# 2 hours ago #}
{# Compact datetime (no year) - ideal for dashboards/recent activity #}
{{ entry.dateCreated|lrCompactDatetime }} {# Jan 22 15:45 or 22 Jan 15:45 #}
{# Exclude year using includeYear parameter #}
{{ entry.dateCreated|lrDatetime('short', null, false) }} {# 22/01 15:45 #}
{{ entry.dateCreated|lrDate('short', false) }} {# 22/01 #}
{{ entry.dateCreated|lrDate('medium', false) }} {# 22 Jan #}
{# Database/API formatting #}
{{ entry.dateCreated|lrForDatabase }} {# 2026-01-22 15:45:32 #}
{{ entry.dateCreated|lrForApi }} {# 2026-01-22T15:45:32+00:00 #}
{{ entry.dateCreated|lrForFilename }} {# 2026-01-22-154532 #}
{# Utility functions #}
{% set now = lrNow() %}
{% if lrIsToday(entry.dateCreated) %}Today{% endif %}
{% if lrIsPast(entry.expiryDate) %}Expired{% endif %}
{% if lrIsFuture(entry.postDate) %}Scheduled{% endif %}By default, string timestamps are assumed to be in UTC and converted to Craft's timezone. For timestamps already in local time (e.g., log files), pass isUtc: false to skip conversion:
PHP:
// Log file timestamp already in local time - don't convert
DateTimeHelper::formatTime($logTimestamp, isUtc: false);
DateTimeHelper::formatDatetime($logTimestamp, isUtc: false);Twig:
{# Last parameter is isUtc (default: true) #}
{# For timestamps already in local time, pass false #}
{{ logEntry.timestamp|lrTime('short', true, false) }}
{# Parameters: length, showSeconds, isUtc #}
{{ logEntry.timestamp|lrDatetime('short', null, true, false) }}
{# Parameters: length, showSeconds, includeYear, isUtc #}
{{ logEntry.timestamp|lrDate('short', true, false) }}
{# Parameters: length, includeYear, isUtc #}When to use isUtc: false:
- Log file timestamps (already written in server's local time)
- User-entered times without timezone info
- Any string timestamp that's already in the target timezone
European Client (24-hour, DD/MM/YYYY numeric):
return [
'timeFormat' => '24',
'monthFormat' => 'numeric',
'dateOrder' => 'dmy',
'dateSeparator' => '/',
];
// Output: 22/01/2026 15:45US Client (12-hour AM/PM, Jan 22, 2026):
return [
'timeFormat' => '12',
'monthFormat' => 'short',
'dateOrder' => 'mdy',
];
// Output: Jan 22, 2026 3:45 PMFormal Style (January 22, 2026):
return [
'timeFormat' => '24',
'monthFormat' => 'long',
'dateOrder' => 'mdy',
];
// Output: January 22, 2026 15:45ISO Standard (24-hour, YYYY-MM-DD):
return [
'timeFormat' => '24',
'monthFormat' => 'numeric',
'dateOrder' => 'ymd',
'dateSeparator' => '-',
];
// Output: 2026-01-22 15:45Overriding in Templates:
The monthFormat config sets the default, but you can always override per-call:
{# Uses config default (e.g., numeric → 22/01/2026) #}
{{ entry.dateCreated|lrDate }}
{# Force short month names regardless of config #}
{{ entry.dateCreated|lrDate('medium') }} {# 22 Jan 2026 #}
{# Force full month names regardless of config #}
{{ entry.dateCreated|lrDate('long') }} {# 22 January 2026 #}AJAX Response (Controller):
use lindemannrock\base\helpers\DateTimeHelper;
public function actionGetLogs(): Response
{
$logs = $this->logsService->getLogs();
foreach ($logs as &$log) {
$log['dateFormatted'] = DateTimeHelper::formatDatetime($log['dateCreated']);
$log['timeFormatted'] = DateTimeHelper::formatTime($log['dateCreated'], showSeconds: true);
}
return $this->asJson([
'success' => true,
'logs' => $logs,
'exportedAt' => DateTimeHelper::forApi(DateTimeHelper::now()),
]);
}CSV Export:
use lindemannrock\base\helpers\DateTimeHelper;
public function actionExportCsv(): Response
{
$data = $this->service->getData();
$output = fopen('php://temp', 'r+');
// Header row
fputcsv($output, ['Date', 'Time', 'Message', 'Status']);
// Data rows with formatted dates
foreach ($data as $row) {
fputcsv($output, [
DateTimeHelper::formatDate($row['dateCreated']),
DateTimeHelper::formatTime($row['dateCreated'], showSeconds: true),
$row['message'],
$row['status'],
]);
}
rewind($output);
$content = stream_get_contents($output);
fclose($output);
// Filename with timestamp
$filename = 'export-' . DateTimeHelper::forFilename() . '.csv';
return $this->response
->setHeader('Content-Type', 'text/csv')
->setHeader('Content-Disposition', "attachment; filename=\"{$filename}\"")
->setContent($content);
}JSON Export:
use lindemannrock\base\helpers\DateTimeHelper;
public function actionExportJson(): Response
{
$data = $this->service->getData();
$export = [
'exportedAt' => DateTimeHelper::forApi(DateTimeHelper::now()),
'timezone' => Craft::$app->getTimeZone(),
'records' => array_map(fn($row) => [
'id' => $row['id'],
'date' => DateTimeHelper::formatDate($row['dateCreated']),
'time' => DateTimeHelper::formatTime($row['dateCreated']),
'datetime' => DateTimeHelper::formatDatetime($row['dateCreated']),
'iso' => DateTimeHelper::forApi($row['dateCreated']),
'message' => $row['message'],
], $data),
];
$filename = 'export-' . DateTimeHelper::forFilename() . '.json';
return $this->response
->setHeader('Content-Type', 'application/json')
->setHeader('Content-Disposition', "attachment; filename=\"{$filename}\"")
->setContent(json_encode($export, JSON_PRETTY_PRINT));
}Log Viewer Template (Twig):
<table>
<thead>
<tr>
<th>Time</th>
<th>Level</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for entry in logEntries %}
<tr>
<td>
<time datetime="{{ entry.timestamp|lrForApi }}">
{{ entry.timestamp|lrTime('short', true) }}
</time>
</td>
<td>{{ entry.level|upper }}</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>When updating existing code to use DateTimeHelper, replace the old patterns:
Before (Manual timezone conversion):
$utcDate = new \DateTime($result['lastHit'], new \DateTimeZone('UTC'));
$utcDate->setTimezone(new \DateTimeZone(Craft::$app->getTimeZone()));
$result['lastHitFormatted'] = Craft::$app->getFormatter()->asDatetime($utcDate, 'short');After:
$result['lastHitFormatted'] = DateTimeHelper::formatDatetime($result['lastHit']);Before (Direct formatter):
$formatter = Craft::$app->getFormatter();
$log['datetimeFormatted'] = $formatter->asDatetime($log['dateCreated'], 'medium');After:
$log['datetimeFormatted'] = DateTimeHelper::formatDatetime($log['dateCreated'], 'medium');Before (Manual format strings):
$date->format('Y-m-d H:i:s'); // For database
$date->format('c'); // For API
date('Y-m-d-His'); // For filename
$date->format('M j, Y'); // For displayAfter:
DateTimeHelper::forDatabase($date); // For database
DateTimeHelper::forApi($date); // For API
DateTimeHelper::forFilename(); // For filename
DateTimeHelper::formatDate($date, 'medium'); // For displayBefore (Twig):
{{ entry.timestamp|date('H:i:s') }}
{{ entry.timestamp|date('Y-m-d') }}
{{ entry.timestamp|date('M j, Y') }}After:
{{ entry.timestamp|lrTime('short', true) }}
{{ entry.timestamp|lrDate }}
{{ entry.timestamp|lrDate('medium') }}| Method/Filter | Output Example | Use Case |
|---|---|---|
formatDatetime() / |lrDatetime |
22/01/2026 15:45 | General display |
formatDatetime($d, 'long') |
22 January 2026 at 15:45 | Detailed display |
formatCompactDatetime() / |lrCompactDatetime |
22 Jan 15:45 | Dashboards/recent activity |
formatDatetime($d, 'short', null, false) |
22/01 15:45 | Datetime without year |
formatDate() / |lrDate |
22/01/2026 | Date only |
formatDate($d, 'long') |
22 January 2026 | Long date |
formatDate($d, 'short', false) |
22/01 | Date without year |
formatTime() / |lrTime |
15:45 | Time only |
formatTime($d, showSeconds: true) |
15:45:32 | Time with seconds |
formatShortDate() / |lrShortDate |
Jan 22 | Charts/compact |
formatRelative() / |lrRelative |
2 hours ago | Relative time |
forDatabase() / |lrForDatabase |
2026-01-22 15:45:32 | MySQL storage |
forDatabaseDate() |
2026-01-22 | MySQL date only |
forDatabaseDayStart() |
2026-01-22 00:00:00 | Date range start |
forDatabaseDayEnd() |
2026-01-22 23:59:59 | Date range end |
forApi() / |lrForApi |
2026-01-22T15:45:32+00:00 | JSON APIs |
forFilename() / |lrForFilename |
2026-01-22-154532 | Export filenames |
now() / lrNow() |
DateTime object | Current time |
isToday() / lrIsToday() |
true/false | Check if today |
isPast() / lrIsPast() |
true/false | Check if past |
isFuture() / lrIsFuture() |
true/false | Check if future |
Provides centralized CSV, JSON, and Excel export functionality for all LindemannRock plugins. Handles date formatting, response headers, and consistent file naming.
Add to config/lindemannrock-base.php to control which export formats are available:
return [
// ... other settings ...
// Export formats
// Defaults: csv=true, json=false, excel=true
'exports' => [
'csv' => true,
'json' => false, // Developer format - disabled by default
'excel' => true,
],
];ExportHelper uses consistent format identifiers. The isFormatEnabled() method accepts aliases for convenience:
| Config Key | Accepted Values | File Extension |
|---|---|---|
csv |
'csv' |
.csv |
json |
'json' |
.json |
excel |
'excel', 'xlsx', 'xls' |
.xlsx |
Best Practice: Use 'excel' in templates and URL params for consistency with config keys. The export-menu component uses 'excel'.
use lindemannrock\base\helpers\ExportHelper;
// Check enabled formats - accepts config keys and aliases
ExportHelper::isFormatEnabled('excel'); // true (config key)
ExportHelper::isFormatEnabled('xlsx'); // true (alias)
ExportHelper::isFormatEnabled('csv'); // true
$formats = ExportHelper::getEnabledFormats(); // ['csv', 'json', 'excel']
// Generate filename - 3 patterns supported:
// 1. Standard pattern with settings (recommended)
$settings = MyPlugin::$plugin->getSettings();
$filename = ExportHelper::filename($settings, ['logs', $dateRange], 'xlsx');
// → "my-plugin-logs-last30days-2026-01-24-153045.xlsx"
// 2. Simple with timestamp
$filename = ExportHelper::filename('sms-logs', 'csv');
// → "sms-logs-2026-01-24-153045.csv"
// 3. Exact name (no modification)
$filename = ExportHelper::filename('exact-name.csv');
// → "exact-name.csv"
// Check for empty data - redirect with flash message (recommended for CP)
if (empty($rows)) {
Craft::$app->getSession()->setError(Craft::t('my-plugin', 'No logs to export.'));
return $this->redirect(Craft::$app->getRequest()->getReferrer());
}
// Or throw exception for API exports
ExportHelper::assertNotEmpty($rows); // Throws BadRequestHttpException: "Nothing to export."
ExportHelper::assertNotEmpty($rows, 'Custom message'); // Custom error message
// CSV export
return ExportHelper::toCsv($rows, $headers, $filename, ['dateCreated']);
// CSV string (for zips)
$csvContent = ExportHelper::csvContent($rows, $headers, ['dateCreated']);
// JSON export
return ExportHelper::toJson($rows, $filename, ['dateCreated']);
// Excel export with options
return ExportHelper::toExcel($rows, $headers, $filename, ['dateCreated'], [
'sheetTitle' => 'SMS Logs', // Sheet name (max 31 chars)
'freezeHeader' => true, // Freeze header row (default: true)
'autoFilter' => true, // Add filter dropdowns (default: true)
'columnWidths' => ['A' => 20], // Custom column widths (optional)
]);
// Excel export (multiple sheets)
$sheets = [
[
'title' => 'Recent Searches',
'headers' => $headers,
'rows' => $rows,
'dateColumns' => ['dateCreated'],
],
[
'title' => 'Trends',
'headers' => $trendHeaders,
'rows' => $trendRows,
],
];
return ExportHelper::toExcelMulti($sheets, ExportHelper::filename($settings, ['analytics'], 'xlsx'));
// ZIP export (multiple files)
$zipName = ExportHelper::filename($settings, ['reports'], 'zip');
$files = [
['name' => 'recent-searches.csv', 'content' => $csvContent],
'summary.json' => $jsonContent,
];
return ExportHelper::toZip($files, $zipName);Recommended: Use the export-menu component instead of building manually:
{% include 'lindemannrock-base/_components/export-menu' with {
action: 'my-plugin/export',
permission: 'myPlugin:export',
} only %}Manual export links (use 'excel' not 'xlsx' for consistency):
{# Check if format is enabled #}
{% if lrExportEnabled('excel') %}
<a href="{{ cpUrl('my-plugin/export', {format: 'excel'}) }}">Export as Excel</a>
{% endif %}
{# Build export menu from enabled formats #}
<div class="menu">
{% for format in lrExportFormats() %}
<a href="{{ cpUrl('my-plugin/export', {format: format}) }}">
{{ format|upper }}
</a>
{% endfor %}
</div>| Method | Output | Use Case |
|---|---|---|
toCsv($rows, $headers, $filename, $dateColumns) |
CSV file | Spreadsheet-compatible |
toJson($data, $filename, $dateColumns) |
JSON file | API/data exchange |
toExcel($rows, $headers, $filename, $dateColumns, $options) |
XLSX file | Professional reports |
filename($prefix, $extension) |
Timestamped filename | Consistent naming |
isFormatEnabled($format) |
boolean | Check availability (accepts aliases) |
getEnabledFormats() |
array | List all enabled formats |
getFormatOptions() |
array | Options for select fields |
formatDateColumns($rows, $dateColumns) |
Formatted rows | Database format dates |
formatDateColumnsForApi($rows, $dateColumns) |
Formatted rows | ISO 8601 dates |
The toExcel() method creates professionally styled spreadsheets:
- Header styling: Bold white text on dark background
- Frozen header row: Stays visible while scrolling
- Auto-filter dropdowns: Easy data filtering
- Auto-sized columns: Fits content (or use custom widths)
- Alternating row colors: Improved readability
- Thin borders: Clean grid appearance
use lindemannrock\base\helpers\ExportHelper;
use yii\web\ForbiddenHttpException;
public function actionExport(): Response
{
$this->requirePermission('myPlugin:export');
$request = Craft::$app->getRequest();
$format = $request->getQueryParam('format', 'csv');
$dateRange = $request->getQueryParam('dateRange', 'last30days');
// Check if format is enabled (accepts 'excel', 'xlsx', 'csv', 'json')
if (!ExportHelper::isFormatEnabled($format)) {
throw new ForbiddenHttpException('Export format not available');
}
// Get and prepare data
$logs = $this->logsService->getLogs();
$rows = array_map(fn($log) => [
'dateCreated' => $log['dateCreated'],
'recipient' => $log['recipient'],
'message' => $log['message'],
'status' => $log['status'],
], $logs);
// Check for empty data
if (empty($rows)) {
Craft::$app->getSession()->setError(Craft::t('my-plugin', 'No data to export.'));
return $this->redirect($request->getReferrer());
}
$headers = ['Date', 'Recipient', 'Message', 'Status'];
$dateColumns = ['dateCreated'];
$settings = MyPlugin::$plugin->getSettings();
// Route to appropriate export method
return match ($format) {
'excel' => ExportHelper::toExcel(
$rows,
$headers,
ExportHelper::filename($settings, ['logs', $dateRange], 'xlsx'),
$dateColumns,
),
'json' => ExportHelper::toJson(
$rows,
ExportHelper::filename($settings, ['logs', $dateRange], 'json'),
$dateColumns,
),
default => ExportHelper::toCsv(
$rows,
$headers,
ExportHelper::filename($settings, ['logs', $dateRange], 'csv'),
$dateColumns,
),
};
}Centralized user-agent parsing and caching via Matomo DeviceDetector. Use the trait for consistent arrays and optional model mapping.
use lindemannrock\base\helpers\PluginHelper;
use lindemannrock\base\traits\DeviceDetectionTrait;
class DeviceDetectionService
{
use DeviceDetectionTrait;
protected function getDeviceDetectionConfig(): array
{
return [
'cacheEnabled' => true,
'cachePath' => PluginHelper::getCachePath(MyPlugin::$plugin, 'device'),
'cacheKeyPrefix' => PluginHelper::getCacheKeyPrefix(MyPlugin::$plugin->id, 'device'),
'cacheKeySet' => PluginHelper::getCacheKeySet(MyPlugin::$plugin->id, 'device'),
'includePlatform' => true,
'includeLanguage' => true,
'languageDetectionMethod' => 'browser', // browser|ip|both
'enableGeoDetection' => true,
'geoLookupCallback' => fn() => $this->geoService->getCountryCode(),
];
}
}- Cache format is JSON; cache keys and Redis tracking are standardized via
PluginHelper. detectDeviceInfo()returns a normalized array;buildDeviceModel()maps it into a model when needed.- Logging uses the base logging trait when available; otherwise falls back to
Craft::warning()/Craft::error().
Centralizes date range parsing for analytics, logs, dashboards, and any date-filtered pages. Provides consistent date boundaries for all LindemannRock plugins.
Add to config/lindemannrock-base.php to set the default date range:
return [
// ... other settings ...
// Default date range for all date-filtered pages
// Options: 'today', 'yesterday', 'last7days', 'last30days', 'last90days',
// 'thisMonth', 'lastMonth', 'thisYear', 'lastYear', 'all'
'defaultDateRange' => 'last30days',
];| Value | Description |
|---|---|
today |
Current day |
yesterday |
Previous day |
last7days |
Past 7 days |
last30days |
Past 30 days (default) |
last90days |
Past 90 days |
thisMonth |
Current month to date |
lastMonth |
Previous calendar month |
thisYear |
Current year to date |
lastYear |
Previous calendar year |
all |
All time (no date filter) |
use lindemannrock\base\helpers\DateRangeHelper;
// Get default date range from config
$default = DateRangeHelper::getDefaultDateRange(); // 'last30days'
// With plugin-specific override
$default = DateRangeHelper::getDefaultDateRange('sms-manager');
// Get standard options for dropdowns
$options = DateRangeHelper::getOptions();
// Returns: [{value: 'today', label: 'Today'}, {value: 'yesterday', label: 'Yesterday'}, ...]
// Get as associative array
$options = DateRangeHelper::getOptions('assoc');
// Returns: ['today' => 'Today', 'yesterday' => 'Yesterday', ...]
// Normalize date range (handles aliases, null values)
$range = DateRangeHelper::normalize($request->getParam('dateRange'));
// Get date bounds for queries
$bounds = DateRangeHelper::getBounds('last7days');
// Returns: ['start' => DateTime, 'end' => DateTime|null]
// Apply to a query
DateRangeHelper::applyToQuery($query, 'last30days', 'dateCreated');
// Get day count (for averages)
$days = DateRangeHelper::getDaysCount('thisMonth'); // 15 (if today is 15th)public function actionIndex(): Response
{
$dateRange = DateRangeHelper::normalize(
Craft::$app->getRequest()->getParam('dateRange'),
DateRangeHelper::getDefaultDateRange('my-plugin') // Plugin-specific default
);
$query = (new Query())->from('{{%my_analytics}}');
DateRangeHelper::applyToQuery($query, $dateRange);
return $this->renderTemplate('my-plugin/analytics/index', [
'dateRange' => $dateRange,
'stats' => $query->all(),
]);
}{# Get default date range from config #}
{% set defaultRange = lrDefaultDateRange() %}
{# With plugin-specific override #}
{% set defaultRange = lrDefaultDateRange('sms-manager') %}
{# Get date range options for dropdowns #}
{% set options = lrDateRangeOptions() %}
{# Returns: [{value: 'today', label: 'Today'}, {value: 'yesterday', label: 'Yesterday'}, ...] #}
{# Get options as associative array (for cp-analytics layout) #}
{% set options = lrDateRangeOptions('assoc') %}
{# Returns: {today: 'Today', yesterday: 'Yesterday', last7days: 'Last 7 days', ...} #}
{# In cp-analytics config - omit default to use config #}
{% set analyticsConfig = {
filters: {
dateRange: {
current: dateRange,
{# default: will use lrDefaultDateRange() automatically #}
},
},
} %}| Function | Returns | Use Case |
|---|---|---|
lrDefaultDateRange(pluginHandle?) |
string |
Get default range from config (e.g., 'last30days') |
lrDateRangeOptions(format?) |
array |
Get options for dropdowns ('array' or 'assoc' format) |
Provides utilities for parsing and validating CSV file uploads with automatic delimiter detection.
use lindemannrock\base\helpers\CsvImportHelper;
use craft\web\UploadedFile;
// Get uploaded file
$file = UploadedFile::getInstanceByName('csvFile');
// Parse with default options
$result = CsvImportHelper::parseUpload($file);
// Returns: ['headers' => [...], 'allRows' => [...], 'rowCount' => 150, 'delimiter' => ',']
// Parse with custom options
$result = CsvImportHelper::parseUpload($file, [
'maxRows' => 1000, // Maximum rows to parse (default: 4000)
'maxBytes' => 10485760, // Max file size in bytes (default: 5MB)
'allowedExtensions' => ['csv', 'txt'], // Allowed extensions
'delimiter' => ';', // Force specific delimiter (default: auto-detect)
'detectDelimiter' => true, // Auto-detect delimiter (default: true)
]);
// Access parsed data
$headers = $result['headers']; // ['Name', 'Email', 'Phone']
$rows = $result['allRows']; // [['John', 'john@example.com', '123'], ...]
$count = $result['rowCount']; // 150
$delimiter = $result['delimiter']; // ',' or ';' or '\t' or '|'- File validation: Extension, MIME type, and file size checks
- Delimiter detection: Automatically detects comma, semicolon, tab, or pipe delimiters
- Row limits: Configurable maximum rows to prevent memory issues
- Proper cleanup: Temporary files are deleted even on error
- Flexible options: All validation parameters are configurable
| Delimiter | Character |
|---|---|
| Comma | , |
| Semicolon | ; |
| Tab | \t |
| Pipe | | |
use lindemannrock\base\helpers\CsvImportHelper;
use craft\web\UploadedFile;
public function actionImport(): Response
{
$this->requirePostRequest();
$this->requirePermission('myPlugin:import');
$file = UploadedFile::getInstanceByName('csvFile');
if (!$file) {
Craft::$app->getSession()->setError('No file uploaded.');
return $this->redirectToPostedUrl();
}
try {
$result = CsvImportHelper::parseUpload($file, [
'maxRows' => 2000,
]);
// Process rows
foreach ($result['allRows'] as $row) {
// Map headers to values
$data = array_combine($result['headers'], $row);
// ... process $data
}
Craft::$app->getSession()->setNotice(
"Imported {$result['rowCount']} records."
);
} catch (\RuntimeException $e) {
Craft::$app->getSession()->setError($e->getMessage());
}
return $this->redirectToPostedUrl();
}The parseUpload() method throws \RuntimeException with user-friendly messages:
| Error | Message |
|---|---|
| Invalid extension | "Invalid file type. Please upload a CSV file." |
| File too large | "File size exceeds the allowed limit of XMB." |
| Invalid MIME type | "Invalid file type. Please upload a CSV file." |
| Can't read file | "Could not open uploaded file for reading." |
| No headers | "Could not read CSV headers." |
| Single column | "Could not detect CSV delimiter..." |
| Too many rows | "CSV file is too large. Maximum X rows allowed. Please split your file into smaller batches." |
| Empty file | "CSV file is empty or contains only headers." |
Provides centralized color definitions for badges, filters, and status indicators across all LindemannRock plugins. Ensures consistent colors are used in both filter dropdowns and table badges.
ColorHelper provides a unified PALETTE constant with all available colors. This includes Craft's Tailwind-based colors and can be extended with custom colors:
| Class | Hex Color | Use Case |
|---|---|---|
teal |
#14b8a6 | Enabled, live status |
cyan |
#06b6d4 | Information |
gray |
#6b7280 | Disabled, neutral |
orange |
#f97316 | Pending, warning |
red |
#ef4444 | Error, expired, off |
blue |
#3b82f6 | Production, redirect |
pink |
#ec4899 | Development |
purple |
#a855f7 | Debug |
green |
#22c55e | Success, yes, on |
yellow |
#eab308 | Caution |
amber |
#f59e0b | Alert |
emerald |
#10b981 | Positive |
indigo |
#6366f1 | Special |
violet |
#8b5cf6 | Alternative |
fuchsia |
#d946ef | Highlight |
rose |
#f43f5e | Client error |
lime |
#84cc16 | Active |
sky |
#0ea5e9 | Info logs |
| Color Set | Values | Use Case |
|---|---|---|
status |
enabled, disabled, pending, expired, live, on, off | Craft status classes |
yesNo |
yes, no, true, false | Boolean (green/red) |
handled |
yes, no, true, false | Handled state (green/red) |
configSource |
config, database | Configuration source |
environmentType |
development, staging, production | Environment type |
priority |
low, normal, high, critical | Priority levels |
httpStatus |
success, redirect, client_error, server_error | HTTP response types |
logLevel |
debug, info, warning, error | Log severity levels |
pluginStatus |
active, disabled, notInstalled | Plugin installation state |
exportStatus |
pending, processing, completed, failed | Export/job status |
triggerType |
manual, scheduled, api | Trigger source types |
exportFormat |
xlsx, csv, json | Export file formats |
messageStatus |
pending, sent, delivered, failed | Message/notification status |
healthStatus |
ok, low, high | Health checks, sync status, discrepancy levels |
backupReason |
import, restore, manual, scheduled | Backup origin/type |
use lindemannrock\base\helpers\ColorHelper;
// Get a palette color by name (recommended for plugins)
$teal = ColorHelper::getPaletteColor('teal');
// Returns: ['class' => 'teal', 'color' => '#14b8a6', 'rgb' => '20, 184, 166', 'text' => '#115e59']
// Get all available palette color names
$colorNames = ColorHelper::getPaletteColorNames();
// Returns: ['teal', 'cyan', 'gray', 'orange', 'red', 'blue', 'pink', ...]
// Get entire color set
$colors = ColorHelper::getColorSet('status');
// Returns: ['enabled' => ['class' => 'teal', ...], 'disabled' => ['class' => 'gray', ...], ...]
// Get specific color from a set
$enabledColor = ColorHelper::getSetColor('status', 'enabled');
// Returns: ['class' => 'teal', 'color' => '#14b8a6', 'rgb' => '20, 184, 166', 'text' => '#115e59', 'dot' => 'enabled']
// Get neutral color (for unselected filter items)
$neutral = ColorHelper::getNeutralColor();
// Returns: '#aab6c1'
// Get filter color (shows actual color if selected, neutral if not)
$filterColor = ColorHelper::getFilterColor('status', 'enabled', $currentFilter);
// Check if color set exists
if (ColorHelper::hasColorSet('customSet')) { ... }
// Register custom color set at runtime (uses palette colors)
ColorHelper::registerColorSet('myCustomSet', [
'active' => ColorHelper::getPaletteColor('teal'),
'inactive' => ColorHelper::getPaletteColor('gray'),
]);{# Get a palette color by name #}
{% set teal = lrPaletteColor('teal') %}
{# Get all palette color names #}
{% set colorNames = lrPaletteColorNames() %}
{# Get entire color set #}
{% set colors = lrColorSet('status') %}
{# Get specific color from a set #}
{% set enabledColor = lrSetColor('status', 'enabled') %}
<span style="color: {{ enabledColor.color }};">Enabled</span>
{# Get neutral color #}
{% set neutral = lrNeutralColor() %}
{# Get default color (fallback) #}
{% set default = lrDefaultColor() %}
{# Get filter color (colored if selected, neutral if not) #}
{% set filterColor = lrFilterColor('status', 'enabled', currentFilter) %}
<span class="status" style="background: {{ filterColor }};"></span>
{# Check if color set exists #}
{% if lrHasColorSet('customSet') %}...{% endif %}
{# Get all available color sets #}
{% set allSets = lrAvailableColorSets() %}Plugins should register their custom colors in their init() method using PluginHelper::bootstrap():
use lindemannrock\base\helpers\ColorHelper;
use lindemannrock\base\helpers\PluginHelper;
public function init(): void
{
parent::init();
// Bootstrap with custom color sets and log menu
PluginHelper::bootstrap(
$this,
'myPluginHelper',
['myPlugin:viewLogs'],
['myPlugin:downloadLogs'],
[
'colorSets' => [
'myStatus' => [
'active' => ColorHelper::getPaletteColor('teal'),
'pending' => ColorHelper::getPaletteColor('orange'),
'failed' => ColorHelper::getPaletteColor('red'),
],
],
// Optional: Customize log sidebar menu
'logMenu' => [
'label' => 'Logs',
'items' => [
'system' => ['label' => 'System', 'url' => 'my-plugin/logs/system'],
'activity' => ['label' => 'Activity', 'url' => 'my-plugin/logs/activity'],
],
],
]
);
}To add new default color sets to the base module, edit /plugins/base/src/helpers/ColorHelper.php and add to the initialize() method using PALETTE:
'myNewType' => [
'value1' => self::PALETTE['teal'],
'value2' => self::PALETTE['red'],
],
// For status sets with dot classes, use array_merge:
'myStatus' => [
'active' => array_merge(self::PALETTE['teal'], ['dot' => 'enabled']),
'inactive' => array_merge(self::PALETTE['gray'], ['dot' => 'disabled']),
],Each color entry contains:
class- CSS class name for status-label wrapper (matches Craft's classes)color- Solid hex color for dots/indicatorsrgb- RGB values for semi-transparent backgroundstext- Dark text color for readabilitydot- (optional) Inner status dot class (e.g., 'enabled', 'disabled')
Renders colored badges with dot and text. Uses ColorHelper for consistent colors.
Location: lindemannrock-base/_components/badge
{# Using Craft's built-in status colors #}
{% include 'lindemannrock-base/_components/badge' with {
label: 'Enabled',
status: 'green', {# green, red, orange, blue, teal, gray, disabled, all #}
} only %}
{# Using ColorHelper color set (recommended) #}
{% include 'lindemannrock-base/_components/badge' with {
label: item.status|capitalize,
value: item.status,
colorSet: 'smsStatus',
} only %}
{# Using custom colors directly #}
{% include 'lindemannrock-base/_components/badge' with {
label: 'Custom',
color: '#6366f1',
rgb: '99, 102, 241',
textColor: '#312e81',
} only %}
{# With link #}
{% include 'lindemannrock-base/_components/badge' with {
label: 'View',
status: 'green',
url: '/some/url',
title: 'Click to view',
} only %}Renders action buttons or dropdown menus for table rows with permission handling.
Location: lindemannrock-base/_components/row-actions
{# Simple delete button #}
{% include 'lindemannrock-base/_components/row-actions' with {
item: redirect,
actions: {
type: 'button',
icon: 'delete',
permission: 'pluginHandle:delete',
class: 'delete',
jsAction: 'delete',
},
} only %}
{# Dropdown menu with multiple actions #}
{% include 'lindemannrock-base/_components/row-actions' with {
item: item,
actions: {
type: 'menu',
icon: 'settings',
title: 'Actions'|t('app'),
permission: 'pluginHandle:anyAction',
items: [
{
label: 'Edit'|t('app'),
url: url('plugin/edit/' ~ item.id),
permission: 'plugin:edit',
},
{
label: 'Restore'|t('app'),
jsAction: 'restore',
data: {dirname: item.dirname, count: item.count},
},
{type: 'divider'},
{
label: 'Delete'|t('app'),
class: 'error',
permission: 'plugin:delete',
jsAction: 'delete',
confirm: 'Are you sure?',
},
],
},
} only %}Parameters:
item- The current row item (providesitem.idfor data attributes)actions.type-'button'or'menu'actions.icon- Icon name (delete, settings, etc.)actions.permission- Column-level permission (hides entire column if not allowed)actions.items- Array of menu items (fortype: 'menu')label- Display texturl- Link URLpermission- Per-action permission checkshowIf/hideIf- Conditional displayclass- CSS class (e.g.,'error'for destructive)jsAction- JavaScript action name (triggerslr:rowActionevent)data- Key/value map fordata-*attributesconfirm- Confirmation messagetype: 'divider'- Separator line
Renders a phone number input with country code dropdown. Includes auto-detection of country from pasted international numbers, NANP (US/CA) area code detection, and input sanitization.
Location: lindemannrock-base/_components/phone-input
{# Basic usage #}
{% include 'lindemannrock-base/_components/phone-input' with {
id: 'recipient',
label: 'Phone Number',
instructions: 'Enter phone number. Paste with country code to auto-detect.',
defaultCountry: 'US',
} only %}
{# With allowed countries filter #}
{% include 'lindemannrock-base/_components/phone-input' with {
id: 'testPhone',
label: 'Phone Number'|t('my-plugin'),
instructions: 'Enter phone number'|t('my-plugin'),
placeholder: 'e.g., 94400999',
defaultCountry: 'KW',
allowedCountries: ['KW', 'SA', 'AE', 'BH', 'OM', 'QA'],
} only %}Parameters:
id(required) - Input element IDname- Form input name (defaults toid)label- Field labelinstructions- Help textplaceholder- Input placeholdervalue- Initial phone number valuedefaultCountry- Default country code (e.g., 'US', 'KW')allowedCountries- Array of allowed country codes, or['*']for all (default: all)countryId- Country select ID (defaults toid + 'Country')required- Whether field is requiredclass- Additional CSS classes for input
JavaScript API:
// Get full phone number with dial code
const fullNumber = window.lrPhoneInput.getFullNumber('recipient'); // e.g., '15551234567'
// Get local number (without dial code)
const localNumber = window.lrPhoneInput.getLocalNumber('recipient'); // e.g., '5551234567'
// Get selected country code
const country = window.lrPhoneInput.getCountry('recipient'); // e.g., 'US'
// Set country programmatically
window.lrPhoneInput.setCountry('recipient', 'CA');
// Set phone number
window.lrPhoneInput.setNumber('recipient', '5551234567');
// Update allowed countries dynamically (for provider-based filtering)
const dialCodes = [
{country: 'US', dialCode: '1', label: 'US +1'},
{country: 'CA', dialCode: '1', label: 'CA +1'},
{country: 'GB', dialCode: '44', label: 'GB +44'},
];
window.lrPhoneInput.updateAllowedCountries('recipient', dialCodes, 'US');
// Detect country from phone number
const result = window.lrPhoneInput.detectCountry('+15551234567', 'recipient');
// Returns: {dialCode: '1', countryCode: 'US', localNumber: '5551234567'}
// Sanitize phone number
const clean = window.lrPhoneInput.sanitize('+1 (555) 123-4567'); // '15551234567'
// Access NANP area codes for US/CA detection
console.log(window.lrPhoneInput.NANP_AREA_CODES.CA); // ['204', '226', ...]Events:
The component fires custom events on the input element:
// Country changed (via dropdown or paste auto-detect)
document.getElementById('recipient').addEventListener('lr:phoneCountryChanged', function(e) {
console.log(e.detail.country); // 'US'
console.log(e.detail.dialCode); // '1'
console.log(e.detail.inputId); // 'recipient'
});
// Phone number changed
document.getElementById('recipient').addEventListener('lr:phoneNumberChanged', function(e) {
console.log(e.detail.localNumber); // '5551234567'
console.log(e.detail.fullNumber); // '15551234567'
console.log(e.detail.inputId); // 'recipient'
});
// Country not allowed (detected country not in provider's allowed list)
document.getElementById('recipient').addEventListener('lr:phoneCountryNotAllowed', function(e) {
console.log(e.detail.detectedCountry); // 'GB'
console.log(e.detail.detectedDialCode); // '44'
console.log(e.detail.localNumber); // '2079460958'
// Show error to user
Craft.cp.displayError('Country not allowed');
});Features:
- Paste detection: Auto-detects country from pasted numbers with
+or00prefix - Blur detection: Auto-detects and strips dial code from typed numbers (11+ digits)
- NANP support: Differentiates US/CA/Caribbean by area code
- Shared dial codes: Priority mapping for codes shared by multiple countries (+44→GB, +7→RU, +61→AU)
- Smart stripping: Strips dial code even when it matches selected country (e.g., typing
971...with AE selected) - Country validation: Fires
lr:phoneCountryNotAllowedevent when detected country isn't in allowed list - Input sanitization: Removes invisible characters (zero-width spaces, BOM) and non-digits
- Dynamic filtering: Update allowed countries at runtime via
updateAllowedCountries()
Dropdown filter with colored status indicators. Supports ColorHelper integration.
Location: lindemannrock-base/_components/filter-status
{% include 'lindemannrock-base/_components/filter-status' with {
filter: {
param: 'status',
current: statusFilter,
label: 'All Status'|t('my-plugin'),
colorSet: 'smsStatus', {# Use ColorHelper colors #}
options: [
{value: 'all', label: 'All'|t('app'), status: 'all'},
{value: 'sent', label: 'Sent'|t('my-plugin'), colorKey: 'sent'},
{value: 'failed', label: 'Failed'|t('my-plugin'), colorKey: 'failed'},
{value: 'pending', label: 'Pending'|t('my-plugin'), colorKey: 'pending'},
],
},
urlParams: {search: search, sort: sort, dir: dir},
} only %}Grouped filters with multiple sections:
Each group can have its own param and current values, allowing multiple filter parameters in one dropdown:
{% include 'lindemannrock-base/_components/filter-status' with {
filter: {
param: 'status', {# Default param for groups without their own #}
current: statusFilter, {# Default current for groups without their own #}
label: 'All',
groups: [
{
{# Uses default param/current from filter #}
options: [
{value: 'all', label: 'All', status: 'all'},
{value: 'enabled', label: 'Enabled', status: 'green'},
{value: 'disabled', label: 'Disabled', status: 'disabled'},
],
},
{
header: 'Source',
param: 'source', {# Different URL param #}
current: sourceFilter, {# Different current value #}
colorSet: 'configSource',
options: [
{value: 'all', label: 'All Sources', status: 'all'},
{value: 'config', label: 'Config', colorKey: 'config'},
{value: 'database', label: 'Database', colorKey: 'database'},
],
},
{
header: 'Type',
param: 'type',
current: typeFilter,
colorSet: 'environmentType',
options: [
{value: 'all', label: 'All Types', status: 'all'},
{value: 'production', label: 'Production', colorKey: 'production'},
{value: 'development', label: 'Development', colorKey: 'development'},
],
},
],
},
urlParams: urlParams,
} only %}Simple dropdown filter without status indicators.
Location: lindemannrock-base/_components/filter-dropdown
{% include 'lindemannrock-base/_components/filter-dropdown' with {
filter: {
param: 'language',
current: languageFilter,
label: 'All Languages'|t('my-plugin'),
options: [
{value: 'all', label: 'All Languages'},
{value: 'en', label: 'English'},
{value: 'de', label: 'German'},
],
},
urlParams: urlParams,
} only %}Date range picker for filtering by date period.
Location: lindemannrock-base/_components/filter-daterange
{% include 'lindemannrock-base/_components/filter-daterange' with {
filter: {
param: 'dateRange',
current: dateRange,
label: 'Date Range'|t('my-plugin'),
},
urlParams: urlParams,
} only %}Reusable export dropdown menu that automatically shows only enabled formats based on config/lindemannrock-base.php settings.
Location: lindemannrock-base/_components/export-menu
{# Basic usage #}
{% include 'lindemannrock-base/_components/export-menu' with {
action: 'sms-manager/sms-logs/export',
permission: 'smsManager:downloadLogs',
} only %}
{# With extra parameters (filters, site, etc.) #}
{% include 'lindemannrock-base/_components/export-menu' with {
action: 'my-plugin/export',
permission: 'myPlugin:export',
extraParams: {status: statusFilter, provider: providerFilter},
} only %}Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
action |
string | (required) | CP route path (e.g., 'my-plugin/logs/export') |
permission |
string | null |
Permission required to show button (checks currentUser.can()) |
dateRangeParam |
string | 'dateRange' |
URL parameter name for date range |
extraParams |
object | {} |
Additional parameters to pass to export URL |
Important: CP Route Required
The action parameter uses cpUrl() internally, so you must register a CP route in your plugin that maps to the controller action:
// In your plugin's getCpUrlRules() method:
private function getCpUrlRules(): array
{
return [
// ... other routes ...
'my-plugin/logs/export' => 'my-plugin/logs/export',
];
}Without this route, clicking the export button will result in a 404 error.
Features:
- Automatically reads
dateRangefrom the current URL - Only shows formats enabled in config (
lrExportEnabled()) - Hides entire button if no formats are enabled
- Respects permission checks when
permissionis provided - Format order: Excel → CSV → JSON
Output Example:
When all formats are enabled, renders:
<div class="btngroup">
<button type="button" class="btn menubtn" data-icon="download">Export</button>
<div class="menu" data-align="right">
<ul>
<li><a href="...?format=excel">Export as Excel</a></li>
<li><a href="...?format=csv">Export as CSV</a></li>
<li><a href="...?format=json">Export as JSON</a></li>
</ul>
</div>
</div>Renders loading/empty/error containers for async backup lists.
Location: lindemannrock-base/_partials/backup-list
{% include 'lindemannrock-base/_partials/backup-list' with {
idPrefix: 'backup',
loadingMessage: 'Loading backup history...',
emptyMessage: 'No backups found.',
} %}Reusable CSV import form block (file, delimiter, optional backup toggle).
Location: lindemannrock-base/_partials/import-csv
{% include 'lindemannrock-base/_partials/import-csv' with {
action: actionUrl('plugin/import/upload'),
formId: 'uploadForm',
title: 'Import Items',
description: "Import items from a CSV file. You'll be able to map columns and preview before importing.",
csvDescription: "Optional description shown inside the CSV section when using a mode switch.",
csvFormatTip: csvFormatTip,
fileLabel: 'CSV File',
fileInstructions: 'Select a CSV file to import',
delimiterLabel: 'CSV Delimiter',
delimiterInstructions: 'Character used to separate values in your CSV (auto-detect is default)',
showBackupToggle: true,
backupEnabled: settings.backupEnabled,
backupOnImport: settings.backupOnImport,
backupWarning: 'Backups are disabled in settings.',
submitLabel: 'Upload & Map Columns',
showModeSwitch: false, // set true to enable alternate import mode
primaryLabel: 'CSV Import', // optional
secondaryLabel: 'PHP Import', // optional
secondaryHtml: phpImportHtml, // HTML for alternate import section
secondaryDescription: "Optional description shown inside the alternate import section.",
modeDefault: 'csv', // 'csv' or 'secondary'
renderTitleInSection: false, // move title under toggle when using mode switch
} %}A reusable layout for building consistent table/listing pages in the Control Panel.
Location: lindemannrock-base/_layouts/cp-table
{% extends 'lindemannrock-base/_layouts/cp-table' %}
{% set tableConfig = {
plugin: {
handle: 'my-plugin',
name: myHelper.fullName,
},
page: {
title: 'My Items'|t('my-plugin'),
subnav: 'items',
crumbs: [
{ label: myHelper.fullName, url: url('my-plugin') },
{ label: 'Items'|t('my-plugin'), url: url('my-plugin/items') }
],
},
filters: [
{
type: 'status',
param: 'status',
current: statusFilter,
label: 'All Status'|t('my-plugin'),
colorSet: 'enabledStatus',
options: [
{value: 'all', label: 'All', status: 'all'},
{value: 'enabled', label: 'Enabled', colorKey: 'enabled'},
{value: 'disabled', label: 'Disabled', colorKey: 'disabled'},
],
},
{
type: 'dropdown',
param: 'category',
current: categoryFilter,
label: 'All Categories',
options: categoryOptions,
},
{
type: 'dateRange',
param: 'dateRange',
current: dateRange,
label: 'Date Range',
},
],
search: {
placeholder: 'Search items...'|t('my-plugin'),
value: search,
},
sort: {
field: sort,
direction: dir,
},
table: {
columns: [
{key: 'dateCreated', label: 'Created'|t('my-plugin'), sortable: true},
{key: 'name', label: 'Name'|t('my-plugin'), sortable: true},
{key: 'status', label: 'Status'|t('my-plugin'), sortable: true, hideable: true},
],
items: items,
emptyMessage: 'No items found.'|t('my-plugin'),
},
pagination: {
page: page,
limit: limit,
totalCount: totalCount,
itemLabel: {singular: 'item', plural: 'items'},
},
checkboxes: currentUser.can('myPlugin:deleteItems'),
rowActions: true, // Set to false to hide Actions column
} %}
{# Custom table row rendering #}
{% block tableRow %}
<td class="light">{{ item.dateCreated|lrDatetime }}</td>
<td><strong>{{ item.name }}</strong></td>
<td>
{% include 'lindemannrock-base/_components/badge' with {
label: item.status|capitalize,
value: item.status,
colorSet: 'enabledStatus',
} only %}
</td>
{% endblock %}
{# Row actions (per-row buttons/menu) #}
{% block rowActions %}
{% include 'lindemannrock-base/_components/row-actions' with {
item: item,
actions: {
type: 'menu',
icon: 'settings',
permission: 'myPlugin:editItems',
items: [
{label: 'Edit', url: url('my-plugin/items/' ~ item.id)},
{type: 'divider'},
{label: 'Delete', class: 'error', jsAction: 'delete'},
],
},
} only %}
{% endblock %}
{# Toolbar actions (e.g., Export button) #}
{% block toolbarActions %}
<div class="btngroup">
<button type="button" class="btn menubtn" data-icon="download">Export</button>
<div class="menu">
<ul>
<li><a href="{{ url('my-plugin/export', {format: 'csv'}) }}">CSV</a></li>
<li><a href="{{ url('my-plugin/export', {format: 'json'}) }}">JSON</a></li>
</ul>
</div>
</div>
{% endblock %}
{# Bulk actions (shown when items selected) #}
{% block bulkActions %}
{% if currentUser.can('myPlugin:deleteItems') %}
<button type="button" class="btn secondary" id="bulk-delete-btn">
Delete (<span id="selected-count">0</span>)
</button>
{% endif %}
{% endblock %}| Option | Type | Default | Description |
|---|---|---|---|
plugin.handle |
string | '' |
Plugin handle for URL building |
plugin.name |
string | '' |
Plugin display name |
page.title |
string | 'Listing' |
Page title |
page.subnav |
string | '' |
Active subnav item |
page.crumbs |
array | [] |
Breadcrumb items |
filters |
array | [] |
Filter configurations (status, dropdown, dateRange) |
search.placeholder |
string | 'Search...' |
Search input placeholder |
search.value |
string | '' |
Current search value |
sort.field |
string | 'dateCreated' |
Current sort field |
sort.direction |
string | 'desc' |
Sort direction (asc/desc) |
table.columns |
array | [] |
Column definitions with key, label, sortable, hideable, width |
table.items |
array | [] |
Data items to display |
table.emptyMessage |
string | 'No items found.' |
Message when no items |
table.expandable |
bool | false |
Enable click-to-expand rows |
pagination.page |
int | 1 |
Current page number |
pagination.limit |
int | 50 |
Items per page |
pagination.totalCount |
int | 0 |
Total item count |
pagination.itemLabel |
object | {singular: 'item', plural: 'items'} |
Labels for pagination text |
checkboxes |
bool | false |
Enable row selection checkboxes |
rowActions |
bool | true |
Show Actions column (set false for read-only tables) |
newButton |
object/null | null |
New button config: {url, label, permission} |
ajax.enabled |
bool | false |
Enable auto-refresh |
ajax.interval |
int | 0 |
Refresh interval in seconds |
ajax.endpoint |
string | '' |
AJAX endpoint URL |
sidebarMenu.label |
string | title |
Left sidebar nav label (aria-label) |
sidebarMenu.items |
object | {} |
Nav items: {key: {label, url}} |
sidebarMenu.selected |
string | '' |
Currently selected nav item key |
Enable automatic data refresh with a subtle countdown indicator in the footer:
{% set tableConfig = {
ajax: {
enabled: settings.refreshIntervalSecs > 0,
interval: settings.refreshIntervalSecs, // seconds
endpoint: actionUrl('my-plugin/items/get-data'),
},
} %}When enabled, a refresh icon with countdown timer appears inline with the pagination (e.g., "1 – 10 of 26 items | ↻ 45s"). The icon animates green while refreshing.
Listen for refresh data in your custom scripts:
document.addEventListener('lr:refresh', function(e) {
console.log(e.detail); // Refresh response data
});Enable click-to-expand rows for showing additional details:
{% set tableConfig = {
table: {
expandable: true, // Enable expandable rows
// ...
},
} %}
{% block expandableRow %}
<div class="context-label">{{ 'Details'|t('app') }}</div>
<pre>{{ item.context }}</pre>
{% endblock %}When any column has hideable: true, a "View" button automatically appears in the toolbar allowing users to:
- Sort by: Change sort field and direction (only shows visible sortable columns)
- Table Columns: Show/hide columns via checkboxes
- Use defaults: Reset to default column visibility
- Settings persist in localStorage per plugin/page
table: {
columns: [
{key: 'name', label: 'Name', sortable: true}, // Always visible, sortable
{key: 'email', label: 'Email', sortable: true, hideable: true}, // Sortable + can be hidden
{key: 'status', label: 'Status', hideable: true}, // Can be hidden (not sortable)
{key: 'provider', label: 'Provider'}, // Always visible, not sortable
],
}Column properties:
hideable: true- Column appears in "Table Columns" section with checkboxsortable: true- Column appears in "Sort by" dropdown (hidden columns are excluded)- Non-hideable columns (like primary name/ID) are always visible
Add a "New" button to the toolbar:
{% set tableConfig = {
newButton: {
url: url('my-plugin/items/new'),
label: 'New Item'|t('my-plugin'),
permission: 'myPlugin:createItems', // Optional permission check
},
} %}| Block | Purpose |
|---|---|
tableRow |
Custom cell rendering for each row |
rowActions |
Per-row action button/menu |
toolbarActions |
Buttons in the toolbar (outside form) |
bulkActions |
Buttons shown when items are selected |
expandableRow |
Expandable detail content for each row |
sidebar |
Right sidebar content (uses Craft's details pane) |
beforeTable |
Content before table (warnings, info boxes) |
extraToolbar |
Additional toolbar items inside the toolbar form |
extraFooter |
Additional footer content (always visible) |
scripts |
Custom JavaScript for the page |
Use the appropriate block based on when buttons should appear:
| Block | Visibility | Use Case |
|---|---|---|
bulkActions |
Only when items selected | Delete selected (no "delete all" option) |
extraFooter |
Always visible | Export, Delete All/Selected |
Example: Delete only when selected (Campaign Manager pattern)
{% block bulkActions %}
<button type="button" class="btn secondary" id="delete-btn">
{{ 'Delete'|t('app') }} (<span id="selected-count">0</span>)
</button>
{% endblock %}Example: Always visible with selection-aware behavior (SMS Manager pattern)
{% block extraFooter %}
<button type="button" class="btn secondary" id="delete-btn">
<span id="delete-label">{{ 'Delete All'|t('app') }}</span>
</button>
{% include 'lindemannrock-base/_components/export-menu' with {
action: 'my-plugin/export',
selectionAware: true,
idsParam: 'itemIds',
} only %}
{% endblock %}Items from config files typically shouldn't be selected for bulk actions. The cp-table layout can automatically disable checkboxes for items where source == 'config'.
How it works:
- Set
hasConfigItems: truein your table config - Items with
source == 'config'get disabled checkboxes (grayed out) - Items without
sourceproperty or withsource != 'config'are selectable
Enable in your tableConfig:
{% set tableConfig = {
table: {
columns: [...],
items: myItems,
hasConfigItems: true, // Enable config item detection
},
} %}Implementing in your plugin:
Use the ConfigSourceTrait pattern:
trait ConfigSourceTrait
{
public string $source = 'database';
public function canEdit(): bool
{
return $this->source !== 'config';
}
public function isFromConfig(): bool
{
return $this->source === 'config';
}
}Then in your record/model:
class ProviderRecord extends ActiveRecord
{
use ConfigSourceTrait;
// ...
}Config items will automatically have disabled checkboxes and can't be selected for bulk actions.
To add content to the right sidebar (details pane), use the sidebarContent block:
{% block sidebarContent %}
<div class="meta" style="padding: 12px;">
<div class="data">
<div class="heading">{{ "Summary"|t('app') }}</div>
<div class="value">{{ totalCount }} items</div>
</div>
<div class="data">
<div class="heading">{{ "Status"|t('app') }}</div>
<div class="value">Active</div>
</div>
</div>
{% endblock %}The sidebar appears on the right side of the page using Craft's built-in details pane.
Note: We use
sidebarContentinstead ofsidebarto avoid collision with Craft's left sidebar block in_layouts/cp.
Add a left sidebar navigation menu for sub-pages (like settings sections):
{% set tableConfig = {
// ... other config ...
sidebarMenu: {
label: 'Logs'|t('app'),
items: {
system: {label: 'System'|t('app'), url: 'my-plugin/logs/system'},
activity: {label: 'Activity'|t('app'), url: 'my-plugin/logs/activity'},
errors: {label: 'Errors'|t('app'), url: 'my-plugin/logs/errors'},
},
selected: 'system',
},
} %}Parameters:
label- Aria label for the nav element (defaults to page title)items- Object with keys and{label, url}valuesselected- Key of the currently active item
The sidebar menu only renders if there are 2 or more items. Single-item menus are hidden automatically.
Uses Craft's native _includes/nav component for consistent styling with settings pages.
The cp-table layout dispatches these events:
// Selection changed
document.addEventListener('lr:selectionChanged', function(e) {
console.log('Selected count:', e.detail.count);
console.log('Selected IDs:', e.detail.ids);
});
// Row action triggered
document.addEventListener('lr:rowAction', function(e) {
console.log('Action:', e.detail.action);
console.log('Item ID:', e.detail.id);
});
// Access selection API
if (window.lrTableSelection) {
const ids = window.lrTableSelection.getSelectedIds();
const count = window.lrTableSelection.getCount();
}The cp-table layout provides clickable column headers for sorting, but you must implement the actual sorting logic in your template.
Step 1: Mark columns as sortable in your config:
table: {
columns: [
{key: 'name', label: 'Name', sortable: true},
{key: 'handle', label: 'Handle', sortable: true},
{key: 'provider', label: 'Provider'}, {# not sortable #}
],
}Step 2: Get sort parameters from the request:
{% set sort = craft.app.request.getParam('sort', 'name') %}
{% set dir = craft.app.request.getParam('dir', 'asc') %}Step 3: Implement sorting logic before pagination:
{# Sort items #}
{% if sort == 'name' %}
{% set items = items|sort((a, b) => dir == 'asc' ? a.name|lower <=> b.name|lower : b.name|lower <=> a.name|lower) %}
{% elseif sort == 'handle' %}
{% set items = items|sort((a, b) => dir == 'asc' ? a.handle|lower <=> b.handle|lower : b.handle|lower <=> a.handle|lower) %}
{% elseif sort == 'dateCreated' %}
{% set items = items|sort((a, b) => dir == 'asc' ? a.dateCreated <=> b.dateCreated : b.dateCreated <=> a.dateCreated) %}
{% endif %}
{# Then paginate #}
{% set totalCount = items|length %}
{% set paginatedItems = items|slice(offset, limit) %}Sorting tips:
- Use
|lowerfor case-insensitive string sorting - Use
?? ''for nullable fields:(a.field ?? '') <=> (b.field ?? '') - The
<=>spaceship operator returns -1, 0, or 1 for comparison - Boolean fields sort directly:
a.enabled <=> b.enabled
A reusable layout for building consistent analytics/dashboard pages in the Control Panel.
Location: lindemannrock-base/_layouts/cp-analytics
{% extends 'lindemannrock-base/_layouts/cp-analytics' %}
{% set analyticsConfig = {
plugin: {
handle: 'my-plugin',
name: myHelper.fullName,
},
page: {
title: 'Analytics'|t('my-plugin'),
subnav: 'analytics',
crumbs: [
{ label: myHelper.fullName, url: url('my-plugin') },
{ label: 'Analytics'|t('my-plugin'), url: url('my-plugin/analytics') },
],
},
tabs: {
overview: { label: 'Overview'|t('my-plugin') },
details: { label: 'Details'|t('my-plugin') },
},
filters: {
dateRange: {
current: dateRange,
{# default: uses lrDefaultDateRange() from config if omitted #}
},
sites: {
enabled: true,
current: siteId,
sites: craft.app.sites.allSites,
},
custom: [
{
param: 'provider',
current: providerId,
allLabel: 'All Providers'|t('my-plugin'),
options: providerOptions,
},
],
},
export: {
permission: 'myPlugin:exportAnalytics',
action: 'my-plugin/analytics/export',
},
charts: {
prefix: 'myPlugin',
dataEndpoint: 'my-plugin/analytics/get-data',
},
} %}
{# Tab content #}
{% block tabs %}
<div id="overview" class="lr-tab-content">
{% include 'my-plugin/analytics/_partials/overview' %}
</div>
<div id="details" class="lr-tab-content hidden">
{% include 'my-plugin/analytics/_partials/details' %}
</div>
{% endblock %}
{# Chart initialization #}
{% block scripts %}
<script>
document.addEventListener('lr:analyticsInit', function(e) {
window.lrLoadChartData('daily', function(data) {
window.lrCreateChart('daily-chart', 'line', {
labels: data.labels,
datasets: [{ label: 'Views', data: data.values, borderColor: '#0d78f2' }]
});
});
});
</script>
{% endblock %}| Option | Type | Default | Description |
|---|---|---|---|
plugin.handle |
string | '' |
Plugin handle for URL building |
plugin.name |
string | '' |
Plugin display name |
page.title |
string | 'Analytics' |
Page title |
page.subnav |
string | 'analytics' |
Active subnav item |
tabs |
object | {} |
Tab definitions: { tabId: { label: 'Label' } } |
filters.dateRange.default |
string | lrDefaultDateRange() |
Default date range (from config if omitted) |
filters.dateRange.current |
string | Current date range value | |
filters.sites.enabled |
bool | false |
Enable site filter |
filters.sites.current |
string | '' |
Current site ID |
filters.sites.sites |
array | [] |
Available sites |
filters.custom |
array | [] |
Custom filter definitions |
export.permission |
string | null |
Permission for export |
export.action |
string | '' |
Export action URL |
charts.prefix |
string | 'analytics' |
Window variable prefix for charts |
charts.dataEndpoint |
string | '' |
AJAX endpoint for chart data |
The layout provides these pre-styled CSS classes:
| Class | Description |
|---|---|
.lr-tab-content |
Tab content wrapper (use hidden class for non-active) |
.lr-analytics-stats |
Grid container for stat boxes (4 columns) |
.lr-analytics-stats.compact |
Compact grid for 5+ stat boxes |
.lr-stat-box |
Individual stat box |
.lr-stat-box-colored |
Palette-colored stat box (auto-applied with palette param) |
.lr-stat-value |
Large stat value |
.lr-stat-label |
Stat description label |
.lr-analytics-charts |
Grid container for charts |
.lr-analytics-charts.two-columns |
Two-column chart grid |
.lr-chart-container |
Individual chart wrapper |
.lr-chart-container.full-width |
Full-width chart (spans grid) |
.lr-table-scroll |
Scrollable table wrapper |
.lr-section-heading |
Section heading style |
The layout provides global helpers for chart operations:
// Load chart data via AJAX
window.lrLoadChartData('chartType', function(data) {
// Process data
}, { extraParam: 'value' });
// Create a chart using Chart.js
window.lrCreateChart('canvas-id', 'line', {
labels: [...],
datasets: [...]
}, { /* Chart.js options */ });
// Access chart colors
const colors = window.lrChartColors;
// Access config
const config = window.lrAnalyticsConfig;// Analytics initialized (charts ready to load)
document.addEventListener('lr:analyticsInit', function(e) {
console.log('Date range:', e.detail.dateRange);
console.log('Site ID:', e.detail.siteId);
});
// Tab changed
document.addEventListener('lr:tabChanged', function(e) {
console.log('Active tab:', e.detail.tabId);
});Use the base config tooltip for showing config snippets on index listings.
Markup:
<span class="lr-config-info-icon"
data-config="{{ item.rawConfigDisplay|e('html_attr') }}"
data-config-source="config/my-plugin.php"></span>Notes:
data-configis the preformatted config text.data-config-source(optional) renders a small header label inside the tooltip.- Requires
ComponentsAsset(loaded automatically incp-table/cp-table-utilitylayouts).
| Block | Purpose |
|---|---|
tabs |
Tab content containers (required) |
actionButton |
Export/action button area |
extraToolbar |
Additional toolbar items |
scripts |
Custom JavaScript for chart initialization |
Use these components within your tab partials:
Unified Card (Recommended):
The unified-card component is a flexible card that supports 3 different styles:
| Style | Key Parameters | Use Case |
|---|---|---|
| Analytics | value, description, align: 'center' (no title) |
Stats, import previews, dashboards |
| Utility | title, value, color |
Dashboard cards with header |
| Colored | Add palette: 'green' to any style |
Highlight success/error/warning |
{# 1. Analytics Style (centered, no title) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
value: 1234,
description: 'Total Messages',
align: 'center',
} only %}
{# 2. Analytics Style with Color (import preview pattern) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
value: 50,
description: 'Valid',
palette: 'green',
align: 'center',
} only %}
{# 3. Utility Style (with title + dot) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
title: 'Total Messages',
color: '#059669',
value: 1234,
description: 'messages sent',
} only %}
{# 4. Utility Style with Badge #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
title: 'Success Rate',
color: '#10b981',
value: '89%',
badge: '+15%',
badgeType: 'positive',
} only %}
{# 5. With Sub-boxes #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
title: 'Message Status',
color: '#8b5cf6',
value: 1523,
subBoxes: [
{value: 1250, label: 'Sent', color: '#10b981'},
{value: 23, label: 'Failed', color: '#ef4444'},
{value: 250, label: 'Pending', color: '#f59e0b'},
],
} only %}Grid Layouts:
{# Responsive grid (auto-fit) #}
<div class="lr-unified-cards">
{% include '...' %}
{% include '...' %}
</div>
{# Fixed columns (responsive on mobile <769px) #}
<div class="lr-unified-cards cols-4">
{% include '...' with {value: 100, description: 'Total', align: 'center'} only %}
{% include '...' with {value: 50, description: 'Valid', palette: 'green', align: 'center'} only %}
{% include '...' with {value: 20, description: 'Duplicates', palette: 'amber', align: 'center'} only %}
{% include '...' with {value: 5, description: 'Errors', palette: 'red', align: 'center'} only %}
</div>
{# Available: cols-2, cols-3, cols-4, cols-5 #}All Parameters:
| Parameter | Type | Description |
|---|---|---|
title |
string | Card title (shows dot + header when present) |
color |
string | Accent color for dot (hex, default: #0d78f2) |
value |
mixed | Primary value (numbers auto-format) |
valueColor |
string | Custom value text color (defaults to color) |
secondary |
mixed | Secondary value shown as "/ value" |
description |
string | Description/subtitle text |
badge |
string | Badge text beside value |
badgeType |
string | 'default', 'positive', 'negative' |
badgeColor |
string | Custom badge color (overrides badgeType) |
subBoxes |
array | Sub-metrics: [{value, label, color?}] |
palette |
string | Palette color for colored background ('green', 'red', 'amber', etc.) |
align |
string | Content alignment: 'start', 'center', 'end' |
Stat Box (Legacy):
Note: Prefer
unified-cardfor new code.stat-boxis retained for backwards compatibility.
{% include 'lindemannrock-base/_components/stat-box' with {
value: 12345,
label: 'Total Views',
palette: 'green',
} only %}Chart Container:
<div class="lr-chart-container full-width">
<h3>{{ 'Daily Trend'|t('app') }}</h3>
<canvas id="daily-chart"></canvas>
</div>The base plugin includes example templates you can copy and adapt:
| Example | Location | Description |
|---|---|---|
| Badges Reference | _examples/badges.twig |
Visual reference of all color sets and badge styles |
| Table Layout | _examples/table-layout.twig |
Complete cp-table example with all features |
| Grouped Filters | _examples/grouped-filters.twig |
Multi-param grouped filter dropdown |
| Analytics Layout | _examples/analytics-layout.twig |
Complete cp-analytics example with tabs, charts, stats |
Copy the example to your plugin and adapt:
cp plugins/base/src/templates/_examples/table-layout.twig plugins/my-plugin/src/templates/items/index.twigOr reference directly in development:
// In your plugin's getCpUrlRules()
'my-plugin/examples/badges' => ['template' => 'lindemannrock-base/_examples/badges'],
'my-plugin/examples/table' => ['template' => 'lindemannrock-base/_examples/table-layout'],- Documentation: https://github.com/LindemannRock/craft-plugin-base
- Issues: https://github.com/LindemannRock/craft-plugin-base/issues
- Email: support@lindemannrock.com
This plugin is licensed under the MIT License. See LICENSE.md for details.
Developed by LindemannRock