Building a MiniPress Plugin: The Complete Guide

February 2, 2026 Unknown 16 min read

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, and stroke attributes
  • Use currentColor for 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 display
  • mp_after_save_page - Run actions after page save
  • mp_custom_routes - Register custom URL routes
  • mp_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_LOADED first
  • Require core files in this order: config, functions, auth, hooks
  • Call mp_start_session() and mp_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-table CSS 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 forms
  • mp_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:

  1. Block contains a placeholder div with a data-* attribute
  2. Create a render.php that swaps placeholders with real content server-side
  3. 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 $_GET or $_POST data — 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.php focused 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 LIMIT and OFFSET for 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.