-
Notifications
You must be signed in to change notification settings - Fork 514
Creating Community Plugins
This guide walks you through creating a community plugin for ChurchCRM. Plugins allow you to extend ChurchCRM's functionality without modifying core code.
- Overview
- Plugin Structure
- Step-by-Step Guide
- Plugin Manifest (plugin.json)
- Main Plugin Class
- Adding Routes
- Adding Views
- Configuration & Settings
- Using Hooks
- Client-Side JavaScript
- Best Practices
- Testing Your Plugin
- Publishing Your Plugin
ChurchCRM uses a WordPress-style plugin architecture. Plugins can:
- Add new pages and dashboards
- Integrate with external services (APIs, email providers, etc.)
- Add tabs to Person/Family views
- Hook into events (person created, donation received, etc.)
- Add menu items to the navigation
- Inject JavaScript/CSS into pages
| Type | Location | Description |
|---|---|---|
| Core | src/plugins/core/ |
Shipped with ChurchCRM (maintained by core team) |
| Community | src/plugins/community/ |
Third-party plugins (that's you!) |
Every plugin follows this structure:
src/plugins/community/my-awesome-plugin/
├── plugin.json # Required: Plugin manifest
├── src/
│ └── MyAwesomePluginPlugin.php # Required: Main plugin class
├── routes/
│ └── routes.php # Optional: MVC and API routes
├── views/
│ └── dashboard.php # Optional: View templates
├── assets/
│ ├── css/
│ │ └── style.css # Optional: Plugin styles
│ └── js/
│ └── plugin.js # Optional: Plugin JavaScript
├── help.json # Optional: Help documentation
└── README.md # Optional: Developer documentation
mkdir -p src/plugins/community/my-awesome-plugin/src
mkdir -p src/plugins/community/my-awesome-plugin/routes
mkdir -p src/plugins/community/my-awesome-plugin/viewsCreate src/plugins/community/my-awesome-plugin/plugin.json:
{
"id": "my-awesome-plugin",
"name": "My Awesome Plugin",
"description": "A brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"authorUrl": "https://your-website.com",
"type": "community",
"minimumCRMVersion": "7.0.0",
"mainClass": "ChurchCRM\\Plugins\\MyAwesomePlugin\\MyAwesomePluginPlugin",
"dependencies": [],
"settingsUrl": null,
"routesFile": "routes/routes.php",
"settings": [],
"menuItems": [],
"hooks": []
}Create src/plugins/community/my-awesome-plugin/src/MyAwesomePluginPlugin.php:
<?php
namespace ChurchCRM\Plugins\MyAwesomePlugin;
use ChurchCRM\Plugin\AbstractPlugin;
class MyAwesomePluginPlugin extends AbstractPlugin
{
private static ?MyAwesomePluginPlugin $instance = null;
public function __construct(string $basePath = '')
{
parent::__construct($basePath);
self::$instance = $this;
}
public static function getInstance(): ?MyAwesomePluginPlugin
{
return self::$instance;
}
public function getId(): string
{
return 'my-awesome-plugin';
}
public function getName(): string
{
return 'My Awesome Plugin';
}
public function getDescription(): string
{
return 'A brief description of what your plugin does';
}
public function boot(): void
{
// Called every request when plugin is active
// Initialize services, register hooks here
}
public function activate(): void
{
// Called when plugin is enabled
// Run setup tasks, create database tables, etc.
}
public function deactivate(): void
{
// Called when plugin is disabled
// Clean up temporary data
}
public function uninstall(): void
{
// Called when plugin is removed
// Delete all plugin data, drop tables, etc.
}
}- Go to Admin → Plugin Management (
/plugins/management) - Find your plugin in the list
- Click Enable
The manifest defines your plugin's metadata and capabilities.
| Field | Type | Description |
|---|---|---|
id |
string | Unique identifier (lowercase, hyphens only) |
name |
string | Human-readable name |
description |
string | Brief description |
version |
string | Semantic version (e.g., "1.0.0") |
author |
string | Author name |
type |
string | Always "community" for third-party plugins |
minimumCRMVersion |
string | Minimum ChurchCRM version required |
mainClass |
string | Fully-qualified class name |
| Field | Type | Description |
|---|---|---|
authorUrl |
string | Author's website |
dependencies |
array | Other plugin IDs this plugin requires |
settingsUrl |
string | Custom settings page URL (or null for inline) |
routesFile |
string | Path to routes file (relative to plugin root) |
settings |
array | Settings schema for admin UI |
menuItems |
array | Menu items to add to navigation |
hooks |
array | Hooks this plugin listens to |
Define settings that appear in the Plugin Management UI:
{
"settings": [
{
"key": "apiKey",
"label": "API Key",
"type": "password",
"required": true,
"help": "Enter your API key from the service dashboard"
},
{
"key": "enableFeatureX",
"label": "Enable Feature X",
"type": "boolean",
"required": false,
"help": "Turn on advanced features"
},
{
"key": "webhookUrl",
"label": "Webhook URL",
"type": "text",
"required": false,
"help": "URL to receive notifications"
}
]
}Setting Types: text, password, boolean, number, select
Add items to ChurchCRM's navigation:
{
"menuItems": [
{
"parent": "admin",
"label": "My Plugin Dashboard",
"url": "/plugins/my-awesome-plugin/dashboard",
"icon": "fa-solid fa-plug",
"permission": "bAdmin"
}
]
}Parent Options: calendar, people, groups, sundayschool, email, events, deposits, fundraiser, reports, admin, custom
Your plugin class must extend AbstractPlugin and implement required methods.
public function getId(): string; // Return plugin ID
public function getName(): string; // Return display name
public function getDescription(): string; // Return descriptionpublic function boot(): void
{
// Called on every request when plugin is active
// - Initialize services
// - Register hooks
// - Set up autoloaders
}
public function activate(): void
{
// Called when admin enables the plugin
// - Create database tables
// - Initialize default settings
// - Run migrations
}
public function deactivate(): void
{
// Called when admin disables the plugin
// - Clean up temporary files
// - Cancel scheduled tasks
// Note: Don't delete user data here!
}
public function uninstall(): void
{
// Called when plugin is completely removed
// - Drop database tables
// - Delete all plugin data
// - Remove config entries
}public function isConfigured(): bool
{
// Return true if plugin has all required settings
return !empty($this->getConfigValue('apiKey'));
}
public function getConfigurationError(): ?string
{
// Return error message if misconfigured, null if OK
if (empty($this->getConfigValue('apiKey'))) {
return gettext('API Key is required');
}
return null;
}
public function getSettingsSchema(): array
{
// Define settings for the admin UI
return [
[
'key' => 'apiKey',
'label' => gettext('API Key'),
'type' => 'password',
'required' => true,
'help' => gettext('Get this from your account settings'),
],
];
}public function getMenuItems(): array
{
return [
[
'parent' => 'admin',
'label' => gettext('My Plugin'),
'url' => 'plugins/my-awesome-plugin/dashboard',
'icon' => 'fa-solid fa-plug',
],
];
}
public function getHeadContent(): string
{
// Return HTML/JS to inject into <head>
return '<link rel="stylesheet" href="' . $this->getAssetUrl('css/style.css') . '">';
}
public function getFooterContent(): string
{
// Return HTML/JS to inject before </body>
return '<script src="' . $this->getAssetUrl('js/plugin.js') . '"></script>';
}
public function getClientConfig(): array
{
// Data exposed to JavaScript as window.CRM.plugins.{pluginId}
return [
'apiEndpoint' => '/plugins/my-awesome-plugin/api',
'maxItems' => 100,
];
}Always implement the singleton pattern for route access:
private static ?MyAwesomePluginPlugin $instance = null;
public function __construct(string $basePath = '')
{
parent::__construct($basePath);
self::$instance = $this;
}
public static function getInstance(): ?MyAwesomePluginPlugin
{
return self::$instance;
}Create routes/routes.php to add pages and API endpoints.
<?php
use ChurchCRM\dto\SystemURLs;
use ChurchCRM\Plugins\MyAwesomePlugin\MyAwesomePluginPlugin;
use ChurchCRM\Slim\SlimUtils;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;
use Slim\Views\PhpRenderer;
// Get plugin instance
$plugin = MyAwesomePluginPlugin::getInstance();
if ($plugin === null) {
return; // Safety check - don't register routes if plugin not loaded
}
// MVC Routes (HTML pages)
$app->get('/my-awesome-plugin/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('My Plugin Dashboard'),
'pluginData' => $plugin->getData(),
]);
});
// API Routes (JSON endpoints)
$app->group('/my-awesome-plugin/api', function (RouteCollectorProxy $group) use ($plugin): void {
// GET /plugins/my-awesome-plugin/api/items
$group->get('/items', function (Request $request, Response $response) use ($plugin): Response {
$items = $plugin->getItems();
return SlimUtils::renderJSON($response, ['success' => true, 'data' => $items]);
});
// POST /plugins/my-awesome-plugin/api/items
$group->post('/items', function (Request $request, Response $response) use ($plugin): Response {
$body = $request->getParsedBody();
try {
$item = $plugin->createItem($body);
return SlimUtils::renderJSON($response, ['success' => true, 'data' => $item]);
} catch (\Exception $e) {
return SlimUtils::renderErrorJSON($response, $e->getMessage(), [], 400, $e, $request);
}
});
// DELETE /plugins/my-awesome-plugin/api/items/{id}
$group->delete('/items/{id}', function (Request $request, Response $response, array $args) use ($plugin): Response {
$plugin->deleteItem((int) $args['id']);
return SlimUtils::renderJSON($response, ['success' => true]);
});
});Routes are only loaded when the plugin is active. For additional security:
use ChurchCRM\Authentication\AuthenticationManager;
use ChurchCRM\Slim\Middleware\Request\Auth\AdminRoleAuthMiddleware;
// Admin-only routes
$app->group('/my-awesome-plugin/admin', function (RouteCollectorProxy $group) use ($plugin): void {
$group->get('/settings', function ($request, $response) use ($plugin): Response {
// Admin settings page
});
})->add(AdminRoleAuthMiddleware::class);
// Check permissions in route handler
$app->post('/my-awesome-plugin/action', function (Request $request, Response $response) use ($plugin): Response {
$user = AuthenticationManager::getCurrentUser();
if (!$user->isAdmin()) {
return SlimUtils::renderErrorJSON($response, gettext('Admin access required'), [], 403);
}
// ... handle action
});Create PHP view templates in the views/ directory.
Create views/dashboard.php:
<?php
use ChurchCRM\dto\SystemURLs;
// Include ChurchCRM header (navigation, CSS, etc.)
require SystemURLs::getDocumentRoot() . '/Include/Header.php';
?>
<!-- Breadcrumb Navigation -->
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0 bg-light">
<li class="breadcrumb-item">
<a href="<?= SystemURLs::getRootPath() ?>/v2/dashboard">
<i class="fa-solid fa-home"></i>
</a>
</li>
<li class="breadcrumb-item">
<a href="<?= SystemURLs::getRootPath() ?>/plugins"><?= gettext('Plugins') ?></a>
</li>
<li class="breadcrumb-item active"><?= gettext('My Plugin') ?></li>
</ol>
</nav>
</div>
</div>
<!-- Main Content -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="fa-solid fa-plug mr-2"></i><?= gettext('Dashboard') ?>
</h3>
</div>
<div class="card-body">
<p><?= gettext('Welcome to My Awesome Plugin!') ?></p>
<!-- Data Table -->
<table id="itemsTable" class="table table-striped">
<thead>
<tr>
<th><?= gettext('Name') ?></th>
<th><?= gettext('Status') ?></th>
<th><?= gettext('Actions') ?></th>
</tr>
</thead>
<tbody>
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card card-outline card-info">
<div class="card-header">
<h3 class="card-title"><?= gettext('Quick Stats') ?></h3>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li><strong><?= gettext('Total Items') ?>:</strong> <span id="totalItems">0</span></li>
<li><strong><?= gettext('Active') ?>:</strong> <span id="activeItems">0</span></li>
</ul>
</div>
</div>
</div>
</div>
<!-- JavaScript -->
<script nonce="<?= SystemURLs::getCSPNonce() ?>">
$(document).ready(function() {
// Load data via AJAX
$.ajax({
url: window.CRM.root + '/plugins/my-awesome-plugin/api/items',
method: 'GET',
success: function(response) {
if (response.success) {
renderItems(response.data);
}
},
error: function() {
window.CRM.notify(i18next.t('Failed to load data'), { type: 'error' });
}
});
function renderItems(items) {
var tbody = $('#itemsTable tbody');
tbody.empty();
items.forEach(function(item) {
tbody.append(
'<tr>' +
'<td>' + item.name + '</td>' +
'<td><span class="badge badge-' + (item.active ? 'success' : 'secondary') + '">' +
(item.active ? i18next.t('Active') : i18next.t('Inactive')) + '</span></td>' +
'<td><button class="btn btn-sm btn-danger delete-btn" data-id="' + item.id + '">' +
'<i class="fa-solid fa-trash"></i></button></td>' +
'</tr>'
);
});
$('#totalItems').text(items.length);
$('#activeItems').text(items.filter(i => i.active).length);
}
// Delete handler
$(document).on('click', '.delete-btn', function() {
var id = $(this).data('id');
if (confirm(i18next.t('Are you sure?'))) {
$.ajax({
url: window.CRM.root + '/plugins/my-awesome-plugin/api/items/' + id,
method: 'DELETE',
success: function() {
window.CRM.notify(i18next.t('Item deleted'), { type: 'success' });
location.reload();
}
});
}
});
});
</script>
<?php
// Include ChurchCRM footer
require SystemURLs::getDocumentRoot() . '/Include/Footer.php';- Use Bootstrap 4.6.2 classes (not Bootstrap 5)
- Use AdminLTE card components
- Use
window.CRM.notify()for notifications (notalert()) - Use
i18next.t()for JavaScript translations - Use
gettext()for PHP translations - Include CSP nonce on script tags:
nonce="<?= SystemURLs::getCSPNonce() ?>"
Plugins can only access their own config (sandboxed to plugin.{id}.*):
// In your plugin class
// Get a string value
$apiKey = $this->getConfigValue('apiKey');
// Get a boolean value
$enabled = $this->getBooleanConfigValue('enableFeatureX');
// Set a value
$this->setConfigValue('lastSyncTime', date('c'));Settings are stored in the config_cfg table with keys like:
plugin.my-awesome-plugin.apiKeyplugin.my-awesome-plugin.enableFeatureX-
plugin.my-awesome-plugin.enabled(plugin active state)
Hooks let you respond to events in ChurchCRM.
In your boot() method:
use ChurchCRM\Plugin\Hook\HookManager;
use ChurchCRM\Plugin\Hooks;
public function boot(): void
{
// Register action hooks
HookManager::addAction(Hooks::PERSON_CREATED, [$this, 'onPersonCreated']);
HookManager::addAction(Hooks::PERSON_UPDATED, [$this, 'onPersonUpdated']);
HookManager::addAction(Hooks::DONATION_RECEIVED, [$this, 'onDonationReceived']);
}public function onPersonCreated($person): void
{
// $person is the Person model object
$this->syncToExternalService($person);
}
public function onPersonUpdated($person, array $oldData): void
{
// $oldData contains previous values
if ($oldData['email'] !== $person->getEmail()) {
$this->updateEmailInExternalService($person);
}
}
public function onDonationReceived($payment, $deposit): void
{
// Send thank you email, update external system, etc.
$this->sendThankYouEmail($payment);
}| Hook Constant | Parameters | Description |
|---|---|---|
PERSON_CREATED |
$person |
After person record created |
PERSON_UPDATED |
$person, $oldData |
After person record updated |
PERSON_DELETED |
$personId, $personData |
After person deleted |
FAMILY_CREATED |
$family |
After family created |
FAMILY_UPDATED |
$family, $oldData |
After family updated |
DONATION_RECEIVED |
$payment, $deposit |
After donation recorded |
GROUP_MEMBER_ADDED |
$membership, $group, $person |
Person joined group |
GROUP_MEMBER_REMOVED |
$personId, $group |
Person left group |
EVENT_CHECKIN |
$person, $event |
Person checked into event |
Data from getClientConfig() is available in JavaScript:
// Access your plugin's client config
var config = window.CRM.plugins['my-awesome-plugin'];
console.log(config.apiEndpoint); // '/plugins/my-awesome-plugin/api'// Using jQuery (available globally)
$.ajax({
url: window.CRM.root + '/plugins/my-awesome-plugin/api/items',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ name: 'New Item' }),
success: function(response) {
if (response.success) {
window.CRM.notify(i18next.t('Item created!'), { type: 'success' });
}
},
error: function(xhr) {
var error = xhr.responseJSON?.message || i18next.t('An error occurred');
window.CRM.notify(error, { type: 'error' });
}
});Always use window.CRM.notify() instead of alert():
// Success
window.CRM.notify(i18next.t('Operation completed'), { type: 'success' });
// Error
window.CRM.notify(i18next.t('Something went wrong'), { type: 'error' });
// Warning
window.CRM.notify(i18next.t('Please check your input'), { type: 'warning' });
// Info
window.CRM.notify(i18next.t('Did you know...'), { type: 'info' });-
Use namespaces:
ChurchCRM\Plugins\YourPlugin\ - Use Propel ORM for database access
- Use gettext() for all user-facing strings
- Use i18next.t() for JavaScript strings
- Validate all input before processing
- Handle errors gracefully with try/catch
-
Log important events using
LoggerUtils::getAppLogger() - Follow PSR-12 coding standards
- Test thoroughly before releasing
- Don't use raw SQL - use Propel Query classes
- Don't modify core files - use hooks instead
-
Don't hardcode paths - use
SystemURLs::getRootPath() -
Don't use alert() - use
window.CRM.notify() - Don't store sensitive data in client config
- Don't skip security checks for sensitive operations
- Validate all input - never trust user data
- Escape output - prevent XSS attacks
- Check permissions - verify user has access
- Use prepared statements - Propel handles this
- Sanitize file uploads - check types and sizes
- Enable your plugin in Admin → Plugin Management
- Check that menu items appear
- Navigate to your plugin's pages
- Test all CRUD operations
- Test with different user roles
- Check browser console for JavaScript errors
- Check
src/logs/for PHP errors
// Log debug info
use ChurchCRM\Utils\LoggerUtils;
$logger = LoggerUtils::getAppLogger();
$logger->debug('My plugin action', ['data' => $someData]);
$logger->info('Important event happened');
$logger->error('Something went wrong', ['error' => $e->getMessage()]);Check logs at: src/logs/YYYY-MM-DD-app.log
- Test on a fresh ChurchCRM install
- Test with minimum supported CRM version
- Remove debug/test code
- Update version number in plugin.json
- Write clear documentation (README.md)
- Add help.json for in-app documentation
Community plugins can be distributed via:
-
GitHub Repository - Users clone/download to
src/plugins/community/ -
ZIP File - Users extract to
src/plugins/community/
my-awesome-plugin-v1.0.0.zip
└── my-awesome-plugin/
├── plugin.json
├── src/
│ └── MyAwesomePluginPlugin.php
├── routes/
│ └── routes.php
├── views/
│ └── dashboard.php
├── help.json
└── README.md
1. Download my-awesome-plugin-v1.0.0.zip
2. Extract to src/plugins/community/
3. Go to Admin → Plugin Management
4. Find "My Awesome Plugin" and click Enable
5. Configure settings as needed
Study these core plugins for reference:
| Plugin | Complexity | Features |
|---|---|---|
gravatar |
Simple | Config-only, no routes |
google-analytics |
Simple | Head content injection |
mailchimp |
Complex | Routes, views, API integration, hooks |
Browse the source at: src/plugins/core/
- Documentation: ChurchCRM Wiki
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Last updated: February 2026 | ChurchCRM 7.x
- Installation Guide ← Start here!
- First Run Setup
- Features Overview
Day-to-day usage of ChurchCRM
- User Documentation
- People Management
- Groups & Events
- Tools
- Finances
Server management & configuration
- User Management
- System Maintenance
- Configuration
- Troubleshooting
- Localization
Contributing to ChurchCRM
- Quick Start
- Testing & CI/CD
- Code & Architecture
- Localization
- Release & Security