Building a MiniPress Plugin: The Complete Guide
Building a MiniPress Plugin: The Complete Guide
MiniPress has a straightforward plugin system that lets you extend the core without touching any base code. I built it while developing the blog plugin, so everything here comes from real implementation. This guide covers everything you need to know — from directory structure to database tables to hooks to admin menus.
Directory Structure
Plugins live in /plugins/installed/. Each plugin gets its own directory:
/plugins/installed/
└── your-plugin/
├── plugin.php # Required - main plugin file
├── install.sql # Optional - database schema
├── admin/ # Optional - admin pages
├── public/ # Optional - public pages
└── assets/ # Optional - CSS, JS, images
Only plugin.php is mandatory. Everything else is optional depending on what your plugin does.
The blog plugin structure as a real example:
/plugins/installed/blog/
├── plugin.php # Core functions and hooks
├── install.sql # Database tables
├── blocks.php # GrapesJS blocks
├── render.php # Server-side rendering
├── admin/
│ ├── index.php # Post listing
│ ├── edit.php # Post editor with TinyMCE
│ ├── categories.php # Category management
│ └── settings.php # Plugin settings
└── public/
├── index.php # Blog listing page
└── post.php # Individual post display
Step 1: The plugin.php File
This is your plugin's entry point. MiniPress loads this file automatically when plugins are initialized.
Basic structure:
<?php
/**
* Plugin Name: Your Plugin Name
* Description: What your plugin does
* Version: 1.0.0
* Author: Your Name
*/
if (!defined('MP_LOADED')) exit;
// Plugin initialization code
error_log("YOUR PLUGIN LOADED!"); // Helpful for debugging
// Register hooks
mp_add_hook('mp_admin_menu', 'your_plugin_menu');
// Define functions
function your_plugin_menu(&$menu) {
$menu[] = [
'label' => 'Your Plugin',
'url' => '/plugins/installed/your-plugin/admin/',
'icon' => '<svg>...</svg>',
'active' => strpos($_SERVER['REQUEST_URI'], '/your-plugin/') !== false
];
}
Key points:
- The
if (!defined('MP_LOADED'))prevents direct access to the file error_log()helps verify your plugin loaded (check Apache error logs)- Hooks are registered with
mp_add_hook()— we'll cover this in detail - Functions can be defined here or in separate included files
Step 2: Database Tables (Optional)
If your plugin needs data storage, create an install.sql file. This won't auto-run — you'll need to import it manually or create an installer page.
Example install.sql:
-- Create plugin tables
CREATE TABLE IF NOT EXISTS your_plugin_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS your_plugin_settings (
setting_key VARCHAR(100) PRIMARY KEY,
setting_value TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert default settings
INSERT INTO your_plugin_settings (setting_key, setting_value) VALUES
('items_per_page', '10'),
('enable_feature', '1')
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value);
Real example from the blog plugin:
CREATE TABLE IF NOT EXISTS blog_posts (
post_id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
excerpt TEXT,
featured_image VARCHAR(500),
category_id INT,
template_id INT,
author_id INT NOT NULL,
is_published TINYINT(1) DEFAULT 0,
published_at DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES mp_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES blog_categories(category_id) ON DELETE SET NULL,
FOREIGN KEY (template_id) REFERENCES mp_templates(template_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
To install:
mysql -u username -p database_name < /path/to/install.sql
Step 3: Available Hooks
MiniPress uses a simple hook system. Hooks let you inject functionality at specific points without modifying core files.
Current hooks:
mp_admin_menu
Adds items to the admin navigation menu.
mp_add_hook('mp_admin_menu', function(&$menu) {
$menu[] = [
'label' => 'Blog Posts', // Menu text
'url' => '/plugins/installed/blog/admin/', // Where it links
'icon' => '<svg>...</svg>', // SVG icon
'active' => strpos($_SERVER['REQUEST_URI'], '/blog/') !== false // Highlight when active
];
});
Icon tips:
- Use inline SVG (24x24 or 20x20 works well)
- Set
width,height,viewBox,fill, andstrokeattributes - Use
currentColorfor stroke/fill so it inherits menu colors - Icons from Heroicons or Feather work great
Other hooks (coming soon)
The hook system is expandable. Future hooks might include:
mp_before_page_render- Modify page content before displaymp_after_save_page- Run actions after page savemp_custom_routes- Register custom URL routesmp_dashboard_widgets- Add dashboard widgets
Want a new hook? The core hooks.php file makes it easy to add. Look for mp_run_hook() calls in the core.
Step 4: Helper Functions
Your plugin will likely need utility functions. Define them in plugin.php or separate files.
Example from blog plugin:
// Get all items
function your_plugin_get_items($limit = 10, $offset = 0) {
try {
$stmt = mp_db()->prepare("
SELECT * FROM your_plugin_items
ORDER BY created_at DESC
LIMIT ? OFFSET ?
");
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
} catch (Exception $e) {
return [];
}
}
// Get single item
function your_plugin_get_item($id) {
try {
$stmt = mp_db()->prepare("SELECT * FROM your_plugin_items WHERE item_id = ?");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
} catch (Exception $e) {
return null;
}
}
// Settings management
function your_plugin_get_setting($key, $default = null) {
try {
$stmt = mp_db()->prepare("SELECT setting_value FROM your_plugin_settings WHERE setting_key = ?");
$stmt->execute([$key]);
$result = $stmt->fetchColumn();
return $result !== false ? $result : $default;
} catch (Exception $e) {
return $default;
}
}
function your_plugin_set_setting($key, $value) {
try {
$stmt = mp_db()->prepare("
INSERT INTO your_plugin_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)
");
$stmt->execute([$key, $value]);
return true;
} catch (Exception $e) {
return false;
}
}
Database access:
- Use
mp_db()to get the PDO connection - Always use prepared statements (never string concatenation)
- Wrap in try/catch and return sensible defaults on error
- Use
fetchAll()for multiple rows,fetch()for single row,fetchColumn()for single value
Step 5: Admin Pages
Admin pages live in /plugins/installed/your-plugin/admin/. These are regular PHP files that use MiniPress's admin layout.
Basic admin page structure:
<?php
/**
* Your Plugin - Admin Index
*/
define('MP_LOADED', true);
require_once __DIR__ . '/../../../../core/config.php';
require_once __DIR__ . '/../../../../core/functions.php';
require_once __DIR__ . '/../../../../core/auth.php';
require_once __DIR__ . '/../../../../core/hooks.php';
mp_start_session();
mp_require_login(); // Ensures user is logged in
// Load plugins to make your functions available
mp_load_plugins();
// Your page logic
$items = your_plugin_get_items(20, 0);
// Include admin header
require MP_INCLUDES . '/header.php';
?>
<div class="mp-admin-header">
<h1>Your Plugin</h1>
<a href="edit.php" class="mp-btn mp-btn-primary">Add New</a>
</div>
<div style="background: #fff; padding: 2rem; border-radius: 12px;">
<table class="mp-table">
<thead>
<tr>
<th>Title</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td><?= htmlspecialchars($item['title']) ?></td>
<td><?= date('M j, Y', strtotime($item['created_at'])) ?></td>
<td>
<a href="edit.php?id=<?= $item['item_id'] ?>">Edit</a>
<a href="delete.php?id=<?= $item['item_id'] ?>" onclick="return confirm('Delete?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php require MP_INCLUDES . '/footer.php'; ?>
Key patterns:
- Always define
MP_LOADEDfirst - Require core files in this order: config, functions, auth, hooks
- Call
mp_start_session()andmp_require_login()for protected pages - Call
mp_load_plugins()to make plugin functions available - Use
require MP_INCLUDES . '/header.php'and/footer.php'for admin layout - Always
htmlspecialchars()user input when displaying - Use
.mp-btn,.mp-btn-primary,.mp-tableCSS classes (already styled)
Step 6: Form Handling & CSRF Protection
All forms that modify data MUST include CSRF protection.
Example form with CSRF:
<?php
// Handle POST submission
if ($_SERVER['REQUEST_METHOD'] === 'POST' && mp_verify_csrf($_POST['csrf_token'] ?? '')) {
$title = trim($_POST['title'] ?? '');
$content = $_POST['content'] ?? '';
$errors = [];
if (empty($title)) {
$errors[] = 'Title is required';
}
if (empty($errors)) {
try {
$stmt = mp_db()->prepare("
INSERT INTO your_plugin_items (title, content)
VALUES (?, ?)
");
$stmt->execute([$title, $content]);
header('Location: index.php?saved=1');
exit;
} catch (Exception $e) {
$errors[] = 'Failed to save: ' . $e->getMessage();
}
}
}
?>
<!-- The form -->
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= mp_csrf_token() ?>">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content"></textarea>
</div>
<button type="submit" class="mp-btn mp-btn-primary">Save</button>
</form>
CSRF functions:
mp_csrf_token()- Generates token for formsmp_verify_csrf($token)- Validates token on submission- ALWAYS check
$_SERVER['REQUEST_METHOD'] === 'POST'AND verify CSRF together
Step 7: Public Pages (Optional)
Public-facing pages work similarly but don't require authentication.
Example public page:
<?php
define('MP_LOADED', true);
require_once __DIR__ . '/../../../../core/config.php';
require_once __DIR__ . '/../../../../core/functions.php';
require_once __DIR__ . '/../../../../core/hooks.php';
mp_load_plugins();
// Get items
$items = your_plugin_get_items(10, 0);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Public Page</title>
<style>
/* Your styles */
</style>
</head>
<body>
<h1>Items</h1>
<?php foreach ($items as $item): ?>
<article>
<h2><?= htmlspecialchars($item['title']) ?></h2>
<div><?= nl2br(htmlspecialchars($item['content'])) ?></div>
</article>
<?php endforeach; ?>
</body>
</html>
Step 8: URL Routing
By default, your plugin pages are accessed at /plugins/installed/your-plugin/admin/ or /public/. But you probably want cleaner URLs like /your-plugin/.
Add custom routes via .htaccess:
Edit /public_html/.htaccess and add your routes BEFORE the catch-all rule:
# Your Plugin routes (add before the "Rewrite everything else" line)
RewriteRule ^your-plugin/([^/]+)$ plugins/installed/your-plugin/public/item.php?slug=$1 [L,QSA]
RewriteRule ^your-plugin/?$ plugins/installed/your-plugin/public/index.php [L,QSA]
Blog plugin example:
# Blog routes
RewriteRule ^blog/([^/]+)$ plugins/installed/blog/public/post.php?slug=$1 [L,QSA]
RewriteRule ^blog/?$ plugins/installed/blog/public/index.php [L,QSA]
This creates URLs like:
/blog/→ index.php/blog/my-post-slug→ post.php?slug=my-post-slug/blog/?category=tech→ index.php?category=tech (QSA passes query params)
Important: Routes must go BEFORE the catch-all RewriteRule ^(.*)$ site/index.php line, or they'll never match.
Step 9: Settings Pages
Most plugins need a settings page. Here's the pattern:
<?php
define('MP_LOADED', true);
require_once __DIR__ . '/../../../../core/config.php';
require_once __DIR__ . '/../../../../core/functions.php';
require_once __DIR__ . '/../../../../core/auth.php';
require_once __DIR__ . '/../../../../core/hooks.php';
mp_start_session();
mp_require_login();
mp_load_plugins();
// Handle save
if ($_SERVER['REQUEST_METHOD'] === 'POST' && mp_verify_csrf($_POST['csrf_token'] ?? '')) {
your_plugin_set_setting('items_per_page', (int)$_POST['items_per_page']);
your_plugin_set_setting('enable_feature', isset($_POST['enable_feature']) ? '1' : '0');
header('Location: settings.php?saved=1');
exit;
}
// Get current settings
$itemsPerPage = your_plugin_get_setting('items_per_page', 10);
$enableFeature = your_plugin_get_setting('enable_feature', 1);
require MP_INCLUDES . '/header.php';
?>
<div class="mp-admin-header">
<h1>Settings</h1>
</div>
<?php if (isset($_GET['saved'])): ?>
<div style="background: #ecfdf5; border: 1px solid #a7f3d0; color: #047857; padding: 1rem; border-radius: 8px; margin-bottom: 2rem;">
Settings saved!
</div>
<?php endif; ?>
<div style="max-width: 800px;">
<div style="background: #fff; padding: 2rem; border-radius: 12px;">
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= mp_csrf_token() ?>">
<div style="margin-bottom: 1.5rem;">
<label>Items Per Page</label>
<input type="number" name="items_per_page" value="<?= $itemsPerPage ?>" min="1" max="100">
</div>
<div style="margin-bottom: 1.5rem;">
<label>
<input type="checkbox" name="enable_feature" value="1" <?= $enableFeature ? 'checked' : '' ?>>
Enable Feature
</label>
</div>
<button type="submit" class="mp-btn mp-btn-primary">Save Settings</button>
</form>
</div>
</div>
<?php require MP_INCLUDES . '/footer.php'; ?>
Step 10: Adding GrapesJS Blocks (Optional)
If you want your plugin to provide draggable blocks for the page builder, create a blocks.php file.
Example blocks.php:
<?php
if (!defined('MP_LOADED')) exit;
function your_plugin_get_blocks() {
return [
[
'id' => 'your-plugin-widget',
'label' => 'Your Plugin Widget',
'category' => 'Your Plugin',
'content' => '<div class="your-plugin-widget"><h3>Plugin Content</h3><p>This is a custom block from your plugin.</p></div>'
],
[
'id' => 'your-plugin-list',
'label' => 'Item List',
'category' => 'Your Plugin',
'content' => '<div data-plugin-block="item-list" style="padding:2rem;background:#f0f0f0;border:2px dashed #666;"><span>[Item List Placeholder]</span></div>'
]
];
}
Then in your plugin.php, load blocks and register them:
if (file_exists(__DIR__ . '/blocks.php')) {
require_once __DIR__ . '/blocks.php';
}
For dynamic content blocks (like the blog plugin), use marker-based rendering:
- Block contains a placeholder div with a
data-*attribute - Create a
render.phpthat swaps placeholders with real content server-side - Editor shows placeholder, frontend shows rendered data
Complete Example: Simple Plugin
Here's a complete minimal plugin to demonstrate all pieces together:
Directory structure:
/plugins/installed/notes/
├── plugin.php
├── install.sql
└── admin/
├── index.php
└── edit.php
plugin.php:
<?php
/**
* Plugin Name: Simple Notes
* Description: A basic note-taking plugin
* Version: 1.0.0
*/
if (!defined('MP_LOADED')) exit;
// Register admin menu
mp_add_hook('mp_admin_menu', function(&$menu) {
$menu[] = [
'label' => 'Notes',
'url' => '/plugins/installed/notes/admin/',
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path></svg>',
'active' => strpos($_SERVER['REQUEST_URI'], '/notes/') !== false
];
});
// Helper functions
function notes_get_all() {
$stmt = mp_db()->query("SELECT * FROM notes ORDER BY created_at DESC");
return $stmt->fetchAll();
}
function notes_get($id) {
$stmt = mp_db()->prepare("SELECT * FROM notes WHERE note_id = ?");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
install.sql:
CREATE TABLE IF NOT EXISTS notes (
note_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
admin/index.php:
<?php
define('MP_LOADED', true);
require_once __DIR__ . '/../../../../core/config.php';
require_once __DIR__ . '/../../../../core/functions.php';
require_once __DIR__ . '/../../../../core/auth.php';
require_once __DIR__ . '/../../../../core/hooks.php';
mp_start_session();
mp_require_login();
mp_load_plugins();
$notes = notes_get_all();
require MP_INCLUDES . '/header.php';
?>
<div class="mp-admin-header">
<h1>Notes</h1>
<a href="edit.php" class="mp-btn mp-btn-primary">New Note</a>
</div>
<div style="background: #fff; padding: 2rem; border-radius: 12px;">
<?php foreach ($notes as $note): ?>
<div style="border-bottom: 1px solid #eee; padding: 1rem 0;">
<h3><?= htmlspecialchars($note['title']) ?></h3>
<p><?= nl2br(htmlspecialchars($note['content'])) ?></p>
<small><?= date('M j, Y', strtotime($note['created_at'])) ?></small>
</div>
<?php endforeach; ?>
</div>
<?php require MP_INCLUDES . '/footer.php'; ?>
admin/edit.php:
<?php
define('MP_LOADED', true);
require_once __DIR__ . '/../../../../core/config.php';
require_once __DIR__ . '/../../../../core/functions.php';
require_once __DIR__ . '/../../../../core/auth.php';
require_once __DIR__ . '/../../../../core/hooks.php';
mp_start_session();
mp_require_login();
mp_load_plugins();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && mp_verify_csrf($_POST['csrf_token'] ?? '')) {
$title = trim($_POST['title']);
$content = $_POST['content'];
$stmt = mp_db()->prepare("INSERT INTO notes (title, content) VALUES (?, ?)");
$stmt->execute([$title, $content]);
header('Location: index.php');
exit;
}
require MP_INCLUDES . '/header.php';
?>
<h1>New Note</h1>
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= mp_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label>Title</label>
<input type="text" name="title" style="width: 100%; padding: 0.5rem;" required>
</div>
<div style="margin-bottom: 1rem;">
<label>Content</label>
<textarea name="content" style="width: 100%; min-height: 200px; padding: 0.5rem;"></textarea>
</div>
<button type="submit" class="mp-btn mp-btn-primary">Save Note</button>
</form>
<?php require MP_INCLUDES . '/footer.php'; ?>
That's a complete, functional plugin in about 100 lines total.
Best Practices
Security:
- Always use prepared statements for database queries
- Always verify CSRF tokens on POST requests
- Always call
mp_require_login()for admin pages - Always
htmlspecialchars()when outputting user data - Never trust
$_GETor$_POSTdata — validate and sanitize everything
Database:
- Use try/catch blocks around all database operations
- Return sensible defaults (empty arrays, null) on errors
- Use
mp_db()function to get the PDO connection - Foreign keys keep data integrity
Code Organization:
- Keep
plugin.phpfocused on registration and core functions - Put admin UI in
/admin/ - Put public UI in
/public/ - Separate concerns (blocks.php, render.php, etc.)
Performance:
- Don't load unnecessary files in
plugin.php - Use
LIMITandOFFSETfor pagination - Index frequently-queried columns in your database tables
User Experience:
- Show success messages after saves
- Show clear error messages when things fail
- Make admin pages consistent with MiniPress's style
- Use the provided CSS classes (
.mp-btn,.mp-table, etc.)
Debugging Tips
Plugin not loading?
error_log("MY PLUGIN LOADED!");
Check /var/log/apache2/yourdomain-error.log (Linux) or equivalent.
Database errors? Wrap in try/catch and log:
try {
// query here
} catch (Exception $e) {
error_log("DB Error: " . $e->getMessage());
}
Function not found? Make sure you called mp_load_plugins() before using plugin functions.
Admin page shows blank? Check that you required all four core files in the right order.
What's Next?
This guide covers the fundamentals, but MiniPress's plugin system will keep evolving. Future additions might include:
- More hooks for common tasks
- Plugin settings UI framework
- Asset enqueueing system
- Widget system for dashboard
- Plugin marketplace/repository
If you build something with this, I'd love to hear about it. The blog plugin started as a weekend experiment and turned into a full system — your idea might do the same.