Comprehensive guide to ChurchCRM's WordPress-style plugin architecture. Plugins enable core functionality extension without modifying source code.
ChurchCRM uses a plugin system for:
- Extensibility: Add features without forking
- Isolation: Plugin code separate from core
- Activation: Enable/disable plugins dynamically
- Settings: Configuration per plugin
- Hooks: React to system events
| Component | Location | Purpose |
|---|---|---|
| PluginManager | src/ChurchCRM/Plugin/PluginManager.php |
Discovery, loading, activation (static class) |
| AbstractPlugin | src/ChurchCRM/Plugin/AbstractPlugin.php |
Base class for all plugins |
| PluginInterface | src/ChurchCRM/Plugin/PluginInterface.php |
Plugin contract/interface |
| HookManager | src/ChurchCRM/Plugin/Hook/HookManager.php |
WordPress-style actions & filters |
| Hooks | src/ChurchCRM/Plugin/Hooks.php |
Hook point constants |
| Type | Path | Scope |
|---|---|---|
| Core | src/plugins/core/{plugin-name}/ |
Shipped with ChurchCRM |
| Community | src/plugins/community/{plugin-name}/ |
Third-party extensions (future) |
| Management Routes | src/plugins/routes/ |
Admin UI for managing plugins |
| Management Views | src/plugins/views/ |
Plugin list/settings templates |
src/plugins/core/my-plugin/
├── plugin.json # Manifest (REQUIRED)
├── src/
│ └── MyPluginPlugin.php # Main class extending AbstractPlugin
├── routes/
│ └── routes.php # MVC & API routes (optional)
├── views/
│ └── *.php # View templates (optional)
└── help.json # Documentation (optional)
Complete manifest with all available options:
{
"id": "mailchimp",
"name": "MailChimp Integration",
"description": "Sync ChurchCRM contacts with MailChimp mailing lists",
"version": "1.0.0",
"author": "ChurchCRM Team",
"authorUrl": "https://churchcrm.io",
"type": "core",
"minimumCRMVersion": "7.0.0",
"mainClass": "ChurchCRM\\Plugins\\MailChimp\\MailChimpPlugin",
"dependencies": [],
"settingsUrl": "/plugins/mailchimp/settings",
"routesFile": "routes/routes.php",
"settings": [
{
"key": "apiKey",
"label": "MailChimp API Key",
"type": "password",
"required": true,
"help": "Get from MailChimp account settings"
},
{
"key": "syncInterval",
"label": "Sync Interval (hours)",
"type": "number",
"required": false,
"default": "24",
"help": "Automatic sync frequency"
},
{
"key": "enableSync",
"label": "Enable Auto Sync",
"type": "checkbox",
"required": false,
"default": "false"
}
],
"menuItems": [
{
"parent": "email",
"label": "MailChimp Dashboard",
"url": "/plugins/mailchimp/dashboard",
"icon": "fa-brands fa-mailchimp",
"permission": "bEmailMailto"
}
],
"hooks": [
"person.created",
"person.updated",
"person.deleted",
"email.sent"
]
}Manifest Fields:
id- Unique slug (kebab-case)name- Display namedescription- Plugin purposeversion- Semantic versioningtype-"core"or"community"minimumCRMVersion- Minimum CRM version requiredmainClass- Full class name with namespacesettings- Configuration schema (array of setting objects)menuItems- Navigation menu entrieshooks- Hook points this plugin uses
<?php
namespace ChurchCRM\Plugins\MailChimp;
use ChurchCRM\Plugin\AbstractPlugin;
use ChurchCRM\Plugin\Hook\HookManager;
use ChurchCRM\Plugin\Hooks;
class MailChimpPlugin extends AbstractPlugin
{
private static ?MailChimpPlugin $instance = null;
private ?MailChimpService $service = null;
// Singleton pattern
public function __construct(string $basePath = '')
{
parent::__construct($basePath);
self::$instance = $this;
}
public static function getInstance(): ?MailChimpPlugin
{
return self::$instance;
}
// Plugin metadata (required methods)
public function getId(): string
{
return 'mailchimp';
}
public function getName(): string
{
return 'MailChimp Integration';
}
public function getDescription(): string
{
return 'Sync ChurchCRM contacts with MailChimp mailing lists';
}
public function getVersion(): string
{
return '1.0.0';
}
// Initialization (called when plugin is loaded)
public function boot(): void
{
// Lazy-load service
$this->service = new MailChimpService($this);
// Register event handlers
HookManager::addAction(Hooks::PERSON_CREATED, [$this, 'onPersonCreated']);
HookManager::addAction(Hooks::PERSON_UPDATED, [$this, 'onPersonUpdated']);
HookManager::addAction(Hooks::PERSON_DELETED, [$this, 'onPersonDeleted']);
HookManager::addAction(Hooks::EMAIL_SENT, [$this, 'onEmailSent']);
}
// Configuration checking
public function isConfigured(): bool
{
// Check if required settings have values
return !empty($this->getConfigValue('apiKey'));
}
public function getConfigurationError(): ?string
{
if (!$this->isConfigured()) {
return gettext('MailChimp API Key is required for operation');
}
// Additional validation
if (!$this->validateApiKey($this->getConfigValue('apiKey'))) {
return gettext('Provided API Key is invalid or has expired');
}
return null;
}
// Menu items (appear in navigation)
public function getMenuItems(): array
{
if (!$this->isActive()) {
return [];
}
return [
[
'parent' => 'email',
'label' => gettext('MailChimp Dashboard'),
'url' => 'plugins/mailchimp/dashboard',
'icon' => 'fa-brands fa-mailchimp',
'permission' => 'bEmailMailto'
],
[
'parent' => 'email',
'label' => gettext('Sync Settings'),
'url' => 'plugins/mailchimp/settings',
'icon' => 'fa-cog',
'permission' => 'bEmailMailto'
]
];
}
// Settings schema for admin panel
public function getSettingsSchema(): array
{
return [
[
'key' => 'apiKey',
'label' => gettext('API Key'),
'type' => 'password',
'required' => true,
'help' => gettext('Get from MailChimp account settings')
],
[
'key' => 'listId',
'label' => gettext('Default List'),
'type' => 'select',
'options' => $this->getAvailableLists(),
'required' => true
],
[
'key' => 'autoSync',
'label' => gettext('Automatically sync contacts'),
'type' => 'checkbox',
'required' => false
]
];
}
// Hook handlers
public function onPersonCreated($person): void
{
if (!$this->isActive() || !$this->isConfigured()) {
return;
}
try {
$this->service->syncPerson($person, 'create');
} catch (\Exception $e) {
error_log("MailChimp sync error: " . $e->getMessage());
}
}
public function onPersonUpdated($person, array $oldData): void
{
if (!$this->isActive() || !$this->isConfigured()) {
return;
}
// Only sync if relevant fields changed
$relevantFields = ['FirstName', 'LastName', 'Email', 'Phone'];
$changed = false;
foreach ($relevantFields as $field) {
if (($oldData[$field] ?? null) !== $person->{'get' . $field}()) {
$changed = true;
break;
}
}
if ($changed) {
$this->service->syncPerson($person, 'update');
}
}
public function onPersonDeleted($person): void
{
if (!$this->isActive() || !$this->isConfigured()) {
return;
}
$this->service->syncPerson($person, 'delete');
}
public function onEmailSent($email, $recipients): void
{
if (!$this->isActive() || !$this->isConfigured()) {
return;
}
// Log email in MailChimp
$this->service->logEmailActivity($email, $recipients);
}
}Routes are only loaded when plugin is active. Use singleton pattern:
<?php
// src/plugins/core/mailchimp/routes/routes.php
use ChurchCRM\dto\SystemURLs;
use ChurchCRM\Plugins\MailChimp\MailChimpPlugin;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;
use Slim\Views\PhpRenderer;
return function ($app): void {
$plugin = MailChimpPlugin::getInstance();
if ($plugin === null || !$plugin->isActive()) {
return; // Plugin not active
}
// MVC Route (returns HTML)
$app->get('/plugins/mailchimp/dashboard', function (Request $request, Response $response) use ($plugin): Response {
$renderer = new PhpRenderer(__DIR__ . '/../views/');
return $renderer->render($response, 'dashboard.php', [
'sRootPath' => SystemURLs::getRootPath(),
'sPageTitle' => gettext('MailChimp Dashboard'),
'plugin' => $plugin,
'stats' => $plugin->getService()->getDashboardStats(),
'syncStatus' => $plugin->getService()->getSyncStatus()
]);
});
// Settings page
$app->get('/plugins/mailchimp/settings', function (Request $request, Response $response) use ($plugin): Response {
$renderer = new PhpRenderer(__DIR__ . '/../views/');
return $renderer->render($response, 'settings.php', [
'sRootPath' => SystemURLs::getRootPath(),
'sPageTitle' => gettext('MailChimp Settings'),
'plugin' => $plugin,
'settingsSchema' => $plugin->getSettingsSchema()
]);
});
// API route (JSON response)
$app->post('/plugins/mailchimp/api/sync', function (Request $request, Response $response) use ($plugin): Response {
try {
$result = $plugin->getService()->triggerManualSync();
return SlimUtils::renderJSON($response, ['data' => $result]);
} catch (\Exception $e) {
return SlimUtils::renderErrorJSON($response, gettext('Sync failed'), [], 500, $e, $request);
}
});
// API route for saving settings
$app->post('/plugins/mailchimp/api/settings', function (Request $request, Response $response) use ($plugin): Response {
try {
$body = $request->getParsedBody();
// Validate settings
if (empty($body['apiKey'])) {
return SlimUtils::renderErrorJSON($response, gettext('API Key is required'), [], 400);
}
// Save to plugin config
foreach ($body as $key => $value) {
$plugin->setConfigValue($key, $value);
}
return SlimUtils::renderJSON($response, ['data' => ['status' => 'saved']]);
} catch (\Exception $e) {
return SlimUtils::renderErrorJSON($response, gettext('Failed to save settings'), [], 500, $e, $request);
}
});
};<?php
// src/plugins/core/mailchimp/views/dashboard.php
require SystemURLs::getDocumentRoot() . '/Include/Header.php';
?>
<div>
<h1><?= InputUtils::escapeHTML($sPageTitle) ?></h1>
<?php if ($plugin->getConfigurationError()): ?>
<div class="alert alert-warning">
<i class="fa fa-warning"></i>
<?= InputUtils::escapeHTML($plugin->getConfigurationError()) ?>
</div>
<?php endif; ?>
<div class="row">
<div class="col-md-3">
<div class="small-box bg-info">
<div class="inner">
<h3><?= $stats['totalContacts'] ?></h3>
<p><?= gettext('Synced Contacts') ?></p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><?= gettext('Sync Status') ?></h3>
</div>
<div class="card-body">
<p><?= gettext('Last Sync') ?>: <?= $syncStatus['lastSync']?->format('Y-m-d H:i') ?? gettext('Never') ?></p>
<button id="sync-button" class="btn btn-primary">
<i class="fa fa-sync"></i> <?= gettext('Sync Now') ?>
</button>
</div>
</div>
</div>
<script>
document.getElementById('sync-button').addEventListener('click', function() {
fetch(window.CRM.root + '/plugins/mailchimp/api/sync', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
window.CRM.notify(i18next.t('Sync completed'), {type: 'success'});
})
.catch(error => {
window.CRM.notify(i18next.t('Sync failed'), {type: 'error'});
});
});
</script>
<?php require SystemURLs::getDocumentRoot() . '/Include/Footer.php'; ?>Plugins can ONLY access their own config (prefixed with plugin.{id}.):
// Inside plugin class
$apiKey = $this->getConfigValue('apiKey'); // Gets plugin.mailchimp.apiKey
$enabled = $this->getBooleanConfigValue('autoSync'); // Gets boolean value
$this->setConfigValue('lastSync', date('c')); // Sets plugin.mailchimp.lastSyncConfiguration Sandbox:
- Plugins cannot read/write
SystemConfigkeys directly - All plugin settings stored under
plugin.{plugin_id}.{setting_key} - Automatic prefixing prevents conflicts between plugins
Person Lifecycle:
PERSON_PRE_CREATE- Before person is created (pre-save, can prevent)PERSON_CREATED- After person is createdPERSON_PRE_UPDATE- Before person is updatedPERSON_UPDATED- After person is updatedPERSON_DELETED- After person is deletedPERSON_VIEW_TABS- Add tabs to person profile
Family Lifecycle:
FAMILY_PRE_CREATE,FAMILY_CREATED,FAMILY_PRE_UPDATE,FAMILY_UPDATED,FAMILY_DELETED,FAMILY_VIEW_TABS
Financial:
DONATION_RECEIVED- When donation is recordedDEPOSIT_CLOSED- When deposit is finalized
Events:
EVENT_CREATED- When event is createdEVENT_CHECKIN- When person checks inEVENT_CHECKOUT- When person checks out
Groups:
GROUP_MEMBER_ADDED- When person added to groupGROUP_MEMBER_REMOVED- When person removed from group
Email:
EMAIL_PRE_SEND- Before email is sent (can modify)EMAIL_SENT- After email is sent
UI/Menu:
MENU_BUILDING- Building navigation menuDASHBOARD_WIDGETS- Adding dashboard widgetsSETTINGS_PANELS- Adding settings to admin panelADMIN_PAGE- Rendering admin pages
System:
SYSTEM_INIT- System initializationSYSTEM_UPGRADED- After version upgradeCRON_RUN- Periodic task executionAPI_RESPONSE- Before API response sent
// In plugin's boot() method
use ChurchCRM\Plugin\Hook\HookManager;
use ChurchCRM\Plugin\Hooks;
public function boot(): void
{
// Action hook (no return value needed)
HookManager::addAction(Hooks::PERSON_UPDATED, [$this, 'onPersonUpdated']);
// Filter hook (return value required)
HookManager::addFilter(Hooks::EMAIL_PRE_SEND, [$this, 'filterEmailBody']);
}
// Handler methods
public function onPersonUpdated($person, array $oldData): void
{
// Handle person update
}
public function filterEmailBody($emailBody, $recipient): string
{
// Modify email body before sending
return str_replace('{{placeholders}}', 'values', $emailBody);
}PluginManager is a static class (NOT a singleton - no getInstance() method).
use ChurchCRM\Plugin\PluginManager;
// Initialize plugins (done once in src/plugins/index.php)
PluginManager::init($pluginsPath);
// Check if plugin is active
if (PluginManager::isPluginActive('mailchimp')) {
// do something
}
// Get plugin instance
$plugin = PluginManager::getPlugin('mailchimp');
if ($plugin !== null && $plugin->isConfigured()) {
$result = $plugin->getService()->doSomething();
}
// Get all active plugins
$plugins = PluginManager::getAllPlugins();
foreach ($plugins as $plugin) {
if ($plugin->isActive()) {
// do something
}
}
// Enable/disable plugins
PluginManager::enablePlugin('mailchimp');
PluginManager::disablePlugin('mailchimp');
// Uninstall (removes plugin files)
PluginManager::uninstallPlugin('mailchimp');When plugins have their own Slim app entry point, handle errors properly:
<?php
// src/plugins/core/my-plugin/index.php
use Slim\Factory\AppFactory;
$app = AppFactory::create();
// CORRECT - Config-driven error display
$displayErrors = \ChurchCRM\Utils\SystemConfig::debugEnabled();
$app->addErrorMiddleware($displayErrors, true, true)
->setDefaultErrorHandler(function ($request, $exception) use ($app): Response {
$response = $app->getResponseFactory()->createResponse();
return SlimUtils::renderErrorJSON(
$response,
gettext('Plugin error occurred'),
[],
500,
$exception,
$request
);
});
// Load routes
$app->group('', require __DIR__ . '/routes/routes.php');
$app->run();Key Points:
- Use
SystemConfig::debugEnabled()to control error detail display - Always use
SlimUtils::renderErrorJSON()for consistent error responses - Never throw exceptions directly in API routes
| Plugin | Description | Enabled | Has Routes |
|---|---|---|---|
custom-links |
Custom external links in menu | By default | ✅ |
external-backup |
WebDAV cloud backup | Optional | ✅ |
mailchimp |
MailChimp list sync | Optional | ✅ |
gravatar |
Gravatar profile photos | Optional | ❌ |
google-analytics |
GA4 tracking injection | Optional | ❌ |
openlp |
OpenLP projector control | Optional | ❌ |
vonage |
Vonage SMS messaging | Optional | ❌ |
| URL | Purpose |
|---|---|
/plugins/management |
Admin UI lists all plugins |
/plugins/management/{pluginId} |
Redirect with plugin expanded |
/plugins/api/plugins |
API: List all plugins |
/plugins/api/plugins/{id}/enable |
API: Enable plugin |
/plugins/api/plugins/{id}/disable |
API: Disable plugin |
/plugins/api/plugins/{id}/settings |
API: Update plugin settings |
/plugins/{plugin-name}/* |
Plugin-specific routes |
- ✅ Check
isConfigured()before operations - ✅ Return clear error messages from
getConfigurationError() - ✅ Use settings schema for admin UI generation
- ✅ Validate API keys/credentials before saving
- ✅ Check
isActive()in hook handlers before processing - ✅ Use try/catch in hooks to prevent breaking core functionality
- ✅ Log errors but don't throw exceptions from hooks
- ✅ Keep hook handlers fast and focused
- ✅ Always check plugin instance exists before using
- ✅ Use render functions for HTML, JSON for APIs
- ✅ Return appropriate HTTP status codes (201 for create, etc.)
- ✅ Use sanitization (InputUtils) for user input
- ✅ Lazy-load services only when needed
- ✅ Cache expensive operations in plugin config
- ✅ Use database queries efficiently (avoid N+1)
- ✅ Uninstall cleanup - remove temporary data
Related Skills:
- Routing & Project Architecture - Plugin route patterns
- Slim 4 Best Practices - Entry point configuration
- Security Best Practices - Plugin input validation
Last updated: February 16, 2026