lindemannrock / craft-plugin-base
Common utilities and building blocks for LindemannRock Craft CMS plugins
Installs: 57
Dependents: 20
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Language:Twig
Type:yii2-module
pkg:composer/lindemannrock/craft-plugin-base
Requires
- php: ^8.2
- craftcms/cms: ^5.0.0
- phpoffice/phpspreadsheet: ^3.0 || ^5.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
README
Common utilities and building blocks for LindemannRock Craft CMS plugins.
Overview
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)
- DateTimeHelper for centralized date/time formatting with timezone support
- 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)
Requirements
- Craft CMS 5.0+
- PHP 8.2+
Installation
Via Composer
cd /path/to/project
composer require lindemannrock/craft-plugin-base
Using DDEV
cd /path/to/project
ddev composer require lindemannrock/craft-plugin-base
Usage
Edition Support in Plugin Class
use 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>
In Settings Model
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']; } }
In Main Plugin Class
use lindemannrock\base\helpers\PluginHelper; public function init(): void { parent::init(); // Bootstrap base module (registers Twig extension + logging) PluginHelper::bootstrap($this, 'myPluginHelper', ['myPlugin:viewLogs']); // Apply plugin name from config file PluginHelper::applyPluginNameFromConfig($this); }
In Templates
{# 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' } %}
Components
Traits
| Trait | Methods Provided |
|---|---|
EditionTrait |
editions(), isStandard(), isLite(), isPro(), isAtLeast(), isBelow(), requireEdition(), getEditionName(), hasMultipleEditions() |
SettingsDisplayNameTrait |
getDisplayName(), getFullName(), getPluralDisplayName(), getLowerDisplayName(), getPluralLowerDisplayName() |
SettingsPersistenceTrait |
loadFromDatabase(), saveToDatabase() |
SettingsConfigTrait |
isOverriddenByConfig() |
Templates
| 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 |
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 |
Helpers
| 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::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) |
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 |
ColorHelper::getPaletteColor() |
Get a color from the palette by name |
ColorHelper::getPaletteColorNames() |
Get all available palette color names |
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 |
Cache Path Helpers
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/
Plugin Detection Helpers
Check 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) |
Twig Usage
{# 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 |
GeoHelper Usage
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)'}, ...]
Twig Usage
{# 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) |
DateTimeHelper
Provides centralized date/time formatting for all plugins. Respects Craft's timezone and configurable format preferences.
Configuration
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, // Environment-specific overrides // 'production' => [ // 'timeFormat' => '12', // 'monthFormat' => 'short', // ], ];
PHP Usage
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 timezone
Twig Usage
All 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 %}
Local Time Timestamps (isUtc Parameter)
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
Example Configurations
European Client (24-hour, DD/MM/YYYY numeric):
return [ 'timeFormat' => '24', 'monthFormat' => 'numeric', 'dateOrder' => 'dmy', 'dateSeparator' => '/', ]; // Output: 22/01/2026 15:45
US Client (12-hour AM/PM, Jan 22, 2026):
return [ 'timeFormat' => '12', 'monthFormat' => 'short', 'dateOrder' => 'mdy', ]; // Output: Jan 22, 2026 3:45 PM
Formal Style (January 22, 2026):
return [ 'timeFormat' => '24', 'monthFormat' => 'long', 'dateOrder' => 'mdy', ]; // Output: January 22, 2026 15:45
ISO Standard (24-hour, YYYY-MM-DD):
return [ 'timeFormat' => '24', 'monthFormat' => 'numeric', 'dateOrder' => 'ymd', 'dateSeparator' => '-', ]; // Output: 2026-01-22 15:45
Overriding 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 #}
Real-World Examples
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>
Migration Guide
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 display
After:
DateTimeHelper::forDatabase($date); // For database DateTimeHelper::forApi($date); // For API DateTimeHelper::forFilename(); // For filename DateTimeHelper::formatDate($date, 'medium'); // For display
Before (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') }}
Quick Reference
| 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 |
ExportHelper
Provides centralized CSV, JSON, and Excel export functionality for all LindemannRock plugins. Handles date formatting, response headers, and consistent file naming.
Configuration
Add to config/lindemannrock-base.php to control which export formats are available:
return [ // ... other settings ... // Export formats (all default to true if not specified) 'exports' => [ 'csv' => true, 'json' => true, 'excel' => true, // Set false to disable ], ];
Format Values
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'.
PHP Usage
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']); // 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) ]);
Twig Usage
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>
Export Methods
| 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 |
Excel Features
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
Controller Example
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, ), }; }
ColorHelper
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.
Color Palette
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 |
Available Color Sets
| 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 |
PHP Usage
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'), ]);
Twig Usage
{# 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() %}
Plugin Color Registration
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 PluginHelper::bootstrap( $this, 'myPluginHelper', ['myPlugin:viewLogs'], ['myPlugin:downloadLogs'], [ 'colorSets' => [ 'myStatus' => [ 'active' => ColorHelper::getPaletteColor('teal'), 'pending' => ColorHelper::getPaletteColor('orange'), 'failed' => ColorHelper::getPaletteColor('red'), ], 'myType' => [ 'typeA' => ColorHelper::getPaletteColor('purple'), 'typeB' => ColorHelper::getPaletteColor('blue'), ], ], ] ); }
Adding Default Color Sets
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')
Template Components
Badge Component
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 %}
Row Actions Component
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', }, {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)confirm- Confirmation messagetype: 'divider'- Separator line
Phone Input Component
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()
Filter Components
Status Filter
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 %}
Dropdown Filter
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 Filter
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 %}
Export Menu Component
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>
CP Table Layout
A reusable layout for building consistent table/listing pages in the Control Panel.
Location: lindemannrock-base/_layouts/cp-table
Basic Usage
{% 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 %}
Configuration Reference
| 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 |
AJAX Auto-Refresh
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 });
Expandable Rows
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 %}
View Button (Column Visibility)
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
New Button
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
},
} %}
Available Blocks
| 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 |
Footer Buttons: bulkActions vs extraFooter
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 %}
Config Items: Disabling Checkboxes
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.
Sidebar Content
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.
JavaScript Events
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(); }
Column Sorting
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
CP Analytics Layout
A reusable layout for building consistent analytics/dashboard pages in the Control Panel.
Location: lindemannrock-base/_layouts/cp-analytics
Basic Usage
{% 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: {
default: 'last7days',
current: dateRange,
},
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 %}
Configuration Reference
| 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 | 'last7days' |
Default date range |
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 |
CSS Classes
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 |
.lr-stat-box |
Individual stat box |
.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 |
JavaScript Helpers
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;
JavaScript Events
// 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); });
Available Blocks
| Block | Purpose |
|---|---|
tabs |
Tab content containers (required) |
actionButton |
Export/action button area |
extraToolbar |
Additional toolbar items |
scripts |
Custom JavaScript for chart initialization |
Components
Use these components within your tab partials:
Stat Box:
{% include 'lindemannrock-base/_components/stat-box' with {
value: 12345,
label: 'Total Views',
color: '#10b981', {# optional #}
suffix: '%', {# optional #}
id: 'views-stat', {# optional, for JS updates #}
} only %}
Chart Container:
<div class="lr-chart-container full-width"> <h3>{{ 'Daily Trend'|t('app') }}</h3> <canvas id="daily-chart"></canvas> </div>
Example Templates
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 |
Using Examples
Copy the example to your plugin and adapt:
cp plugins/base/src/templates/_examples/table-layout.twig plugins/my-plugin/src/templates/items/index.twig
Or 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'],
Support
- Documentation: https://github.com/LindemannRock/craft-plugin-base
- Issues: https://github.com/LindemannRock/craft-plugin-base/issues
- Email: support@lindemannrock.com
License
This plugin is licensed under the MIT License. See LICENSE.md for details.
Developed by LindemannRock