Skip to content

minnow-framework/minnow

Repository files navigation

Minnow

A modern, MIT-licensed content management system with WordPress database compatibility.

Minnow is designed to provide a clean, modern PHP architecture while maintaining the ability to read and write WordPress databases. This allows gradual migration from WordPress without losing existing content.

Project Goals

  • Clean architecture - Modern PHP 8.1+ with strict typing and namespaces
  • MIT licensed - No GPL dependencies in the core runtime
  • Database compatible - Read/write existing WordPress databases
  • Plugin system - YAML-driven plugin definitions
  • Modern admin - Vue.js administrative interface

Plugin Compilation Process

Minnow provides a workflow to analyze WordPress plugins and generate Minnow-compatible equivalents, with protection for enhanced plugins.

┌─────────────────┐     ┌─────────────────────────────────────┐     ┌─────────────────┐
│                 │     │  data/plugins/my-plugin/            │     │                 │
│  WordPress      │────▶│  ├── plugin.generated.yaml (raw)   │────▶│  Minnow         │
│  Plugin (PHP)   │     │  └── plugin.yaml (enhanced)        │     │  Plugin         │
│                 │     │                                     │     │                 │
└─────────────────┘     └─────────────────────────────────────┘     └─────────────────┘
        │                       │                                           │
   plugin-analyzer         Human/AI Review                          plugin-generator
   (always safe)           (edit plugin.yaml)                       (protected)

File Structure

Each plugin has two manifest files:

File Purpose Overwritten?
plugin.generated.yaml Raw analyzer output Always (safe to regenerate)
plugin.yaml Active manifest used by Minnow Only if status is 'generated'

This separation allows:

  • Re-analyzing WordPress plugins without losing enhancements
  • Diffing to detect upstream changes
  • Safe iterative development

Step 1: Analyze (plugin-analyzer)

The analyzer extracts structural information from WordPress plugins:

./tools/plugin-analyzer/bin/analyze --plugin=/path/to/gravityforms

Output:

  • plugin.generated.yaml - Raw extraction (always written)
  • plugin.yaml - Created if doesn't exist, preserved if enhanced

What it extracts:

  • Database table schemas (from $wpdb->query, dbDelta, etc.)
  • REST API endpoints
  • Admin pages and menus
  • AJAX handlers
  • Shortcodes
  • Settings/options
  • Hook registrations

What it cannot extract:

  • Complex business logic
  • Dynamic behavior
  • External API integrations
  • Template rendering logic

Step 2: Enhance (Human/AI)

Edit plugin.yaml to improve the generated blueprint:

  1. Review extracted schemas for accuracy
  2. Add missing fields or relationships
  3. Define API response transformations
  4. Specify admin UI layouts
  5. Set implementation.status to enhanced
plugin:
  name: 'Gravity Forms'
  version: 2.9.24
  implementation:
    status: enhanced  # Protects from accidental regeneration

Step 3: Generate (plugin-generator)

The generator creates Minnow plugin code from the manifest:

./tools/plugin-generator/bin/generate --plugin=gravityforms

Protection: Plugins with status: enhanced or status: production are protected:

┌─────────────────────────────────────────────────────────────┐
│  PROTECTED PLUGIN                                           │
├─────────────────────────────────────────────────────────────┤
│  Plugin: Gravity Forms                                      │
│  Status: enhanced                                           │
├─────────────────────────────────────────────────────────────┤
│  This plugin has been enhanced and is protected from        │
│  accidental regeneration.                                   │
│                                                             │
│  To regenerate anyway, use: --force                         │
│  (An automatic backup will be created)                      │
└─────────────────────────────────────────────────────────────┘

Use --force to override (creates automatic backup first).

What it generates:

  • Entity classes for each database table
  • REST API controllers with CRUD operations
  • Vue.js admin page components
  • Shortcode handlers
  • Settings registration

Step 4: Detect Upstream Changes

When the WordPress plugin updates, re-run the analyzer:

./tools/plugin-analyzer/bin/analyze --plugin=/path/to/gravityforms

# Compare what changed
diff data/plugins/gravityforms/plugin.generated.yaml \
     data/plugins/gravityforms/plugin.yaml

This shows new tables, endpoints, or fields that may need to be incorporated into your enhanced version.

Compilation Limitations

This process works best for plugins that are primarily:

  • Data-driven (forms, entries, custom post types)
  • CRUD-oriented (create, read, update, delete)
  • Admin-focused (settings pages, list tables)

Plugins with heavy business logic (WooCommerce, complex workflows) will require significant manual implementation after generation.


Core API

Hook System

The hook system provides event-driven programming with events (actions) and filters.

use function Minnow\Core\Hook\{on, emit, onFilter, filter};

Events

Events trigger callbacks at specific points in execution.

// Register a listener
on('user.created', function(User $user) {
    sendWelcomeEmail($user);
});

// With priority (lower runs first, default is 10)
on('app.boot', $earlyHandler, 5);
on('app.boot', $lateHandler, 15);

// Emit an event
emit('user.created', $user);

Filters

Filters transform values through a chain of callbacks.

// Register a filter
onFilter('post.title', function(string $title): string {
    return htmlspecialchars($title);
});

// Chain multiple filters
onFilter('post.content', fn($c) => nl2br($c));
onFilter('post.content', fn($c) => linkify($c));

// Apply filters to a value
$safeTitle = filter('post.title', $rawTitle);
$html = filter('post.content', $markdown);

Additional Functions

// Remove a listener
off('user.created', $callback);

// Check if listeners exist
if (hasListener('custom.event')) { ... }

// Execution counts
$count = emitCount('page.viewed');

// Currently executing hook
$current = current(); // Returns hook name or null

// Check if hook is running
if (isRunning('data.save')) { ... }

Naming Conventions

Use dot notation for namespacing:

// Core events
on('app.boot', ...);
on('app.shutdown', ...);

// Entity events
on('entity.post.created', ...);
on('entity.user.updated', ...);

// Plugin events
on('plugin.gravity-forms.entry.submitted', ...);
on('plugin.woocommerce.order.completed', ...);

Database

The database layer provides a clean PDO-based abstraction with WordPress table prefix support.

use Minnow\Core\Database\Connection;
use function Minnow\Core\Database\{db, query, schema, transaction};

Connection

// Initialize from config
$connection = Connection::fromConfig([
    'host' => 'localhost',
    'database' => 'minnow',
    'username' => 'root',
    'password' => '',
    'prefix' => 'wp_',
]);
Connection::setInstance($connection);

// Or use DSN directly
$connection = new Connection(
    'mysql:host=localhost;dbname=minnow;charset=utf8mb4',
    'username',
    'password',
    'wp_'
);

Query Builder

// Select queries
$posts = query('posts')
    ->select('ID', 'post_title', 'post_status')
    ->where('post_status', 'publish')
    ->where('post_type', 'post')
    ->orderByDesc('post_date')
    ->limit(10)
    ->get();

// Single row
$user = query('users')->where('ID', 1)->first();

// Single value
$title = query('posts')->where('ID', 1)->value('post_title');

// Column values
$emails = query('users')->where('status', 'active')->pluck('user_email');

// Aggregates
$count = query('posts')->where('post_status', 'publish')->count();
$exists = query('users')->where('user_email', $email)->exists();

// Where clauses
query('posts')
    ->where('status', 'publish')           // status = 'publish'
    ->where('views', '>', 100)             // views > 100
    ->orWhere('featured', 1)               // OR featured = 1
    ->whereIn('type', ['post', 'page'])    // type IN ('post', 'page')
    ->whereNull('deleted_at')              // deleted_at IS NULL
    ->whereLike('title', '%hello%')        // title LIKE '%hello%'
    ->whereBetween('price', 10, 50);       // price BETWEEN 10 AND 50

// Pagination
$page1 = query('posts')->forPage(1, 20)->get();  // First 20
$page2 = query('posts')->forPage(2, 20)->get();  // Next 20

CRUD Operations

// Insert (returns ID)
$id = db()->insert('posts', [
    'post_title' => 'Hello World',
    'post_status' => 'draft',
]);

// Update (returns affected rows)
$affected = db()->update('posts',
    ['post_status' => 'publish'],  // SET
    ['ID' => $id]                   // WHERE
);

// Delete (returns affected rows)
$affected = db()->delete('posts', ['ID' => $id]);

// Via query builder
query('posts')->where('status', 'trash')->delete();
query('posts')->where('ID', $id)->update(['views' => 100]);

Transactions

transaction(function($db) {
    $postId = $db->insert('posts', ['post_title' => 'New Post']);
    $db->insert('postmeta', [
        'post_id' => $postId,
        'meta_key' => '_thumbnail_id',
        'meta_value' => 123,
    ]);
    // Auto-commits on success, rolls back on exception
});

Schema Builder

// Create table
schema()->create('items', function($table) {
    $table->id();                           // BIGINT AUTO_INCREMENT PRIMARY KEY
    $table->string('title', 200);           // VARCHAR(200)
    $table->text('description')->nullable(); // TEXT NULL
    $table->decimal('price', 10, 2);        // DECIMAL(10,2)
    $table->boolean('active')->default(1);  // TINYINT(1) DEFAULT 1
    $table->timestamps();                    // created_at, updated_at
    $table->index('title');                 // INDEX
    $table->unique('sku');                  // UNIQUE INDEX
});

// Check existence
if (!schema()->hasTable('items')) { ... }
if (!schema()->hasColumn('items', 'sku')) { ... }

// Drop table
schema()->dropIfExists('items');

Entity System

Active Record-style ORM for database models with WordPress table compatibility.

use Minnow\Core\Entity\{Post, User, Comment};

Finding Entities

// Find by primary key
$post = Post::find(1);
$user = User::findOrFail(1);  // Throws EntityNotFoundException if not found

// Find by condition
$user = User::firstWhere('user_email', 'admin@example.com');

// Find all
$posts = Post::all();

// Query with conditions
$published = Post::where('post_status', 'publish');
$recent = Post::where('post_date', '>', '2024-01-01');

Creating and Updating

// Create new entity
$post = Post::create([
    'post_title' => 'Hello World',
    'post_status' => 'draft',
    'post_type' => 'post',
    'post_author' => 1,
]);

// Or build and save
$post = new Post([
    'post_title' => 'Another Post',
]);
$post->post_status = 'publish';
$post->save();

// Update existing
$post->post_title = 'Updated Title';
$post->save();

// Delete
$post->delete();

Collections

Query results return Collection objects with utility methods:

$posts = Post::where('post_status', 'publish');

// Iteration
foreach ($posts as $post) { ... }

// Utility methods
$titles = $posts->pluck('post_title');
$byAuthor = $posts->groupBy('post_author');
$first = $posts->first();
$count = $posts->count();

// Filtering and sorting
$featured = $posts->filter(fn($p) => $p->menu_order > 0);
$sorted = $posts->sortBy('post_date', 'desc');
$top5 = $posts->take(5);

// Conversion
$array = $posts->toArray();
$json = $posts->toJson();

Defining Custom Entities

use Minnow\Core\Entity\Entity;

class Product extends Entity
{
    protected static string $table = 'products';
    protected static string $primaryKey = 'id';

    // Mass-assignable attributes
    protected static array $fillable = ['name', 'price', 'sku', 'status'];

    // Hidden from toArray/toJson output
    protected static array $hidden = ['cost', 'supplier_id'];

    // Type casting
    protected static array $casts = [
        'id' => 'int',
        'price' => 'float',
        'active' => 'bool',
        'metadata' => 'json',      // Auto JSON encode/decode
        'created_at' => 'datetime', // Returns DateTime object
    ];

    // Auto-manage created_at/updated_at
    protected static bool $timestamps = true;

    // Custom methods
    public function isInStock(): bool
    {
        return $this->quantity > 0;
    }
}

Built-in Entities

Minnow includes entities for WordPress tables:

// Posts
$posts = Post::published();
$pages = Post::ofType('page');
$author = $post->author();  // Returns User
$children = $post->children();

// Users
$user = User::findByEmail('admin@example.com');
$user = User::findByLogin('admin');
$posts = $user->posts();

// Comments
$comments = Comment::forPost($postId);
$approved = Comment::approved();
$replies = $comment->replies();

Authentication

User authentication with WordPress database compatibility.

use function Minnow\Core\Auth\{
    auth, user, authenticated, guest, can, hasRole, isAdmin,
    attempt, logout, hashPassword, verifyPassword
};

Login and Logout

// Attempt login with email or username
if (attempt('admin@example.com', 'password')) {
    echo "Logged in as " . user()->display_name;
}

// Check authentication
if (authenticated()) {
    $currentUser = user();
    $userId = userId();
}

if (guest()) {
    // Not logged in
}

// Logout
logout();

Roles and Capabilities

// Check capabilities (WordPress-compatible)
if (can('manage_options')) {
    // Show admin settings
}

if (can('edit_posts')) {
    // Allow post editing
}

// Check roles
if (hasRole('administrator')) { ... }
if (hasRole('editor')) { ... }

// Shortcut for admin check
if (isAdmin()) {
    // Full admin access
}

Password Hashing

Supports both WordPress phpass ($P$...) and modern bcrypt/argon2:

// Hash a new password (uses bcrypt by default)
$hash = hashPassword('secret123');

// Verify a password (works with both WordPress and modern hashes)
if (verifyPassword('secret123', $hash)) {
    // Password correct
}

// WordPress passwords are automatically rehashed to bcrypt on login

Session Management

$session = auth()->session();

// Store data
$session->set('cart', ['item1', 'item2']);

// Retrieve data
$cart = $session->get('cart', []);

// Flash messages (available for one request)
$session->flash('success', 'Post saved!');
$message = $session->getFlash('success');

// Destroy session
$session->destroy();

Directory Structure

minnow/
├── core/                  # Minnow core (MIT licensed)
│   ├── Hook/              # Event dispatcher ✅
│   ├── Database/          # Database abstraction ✅
│   ├── Entity/            # ORM/Active Record ✅
│   ├── Auth/              # Authentication ✅
│   └── Http/              # Request/Response (planned)
│
├── bridge/                # WordPress bridge (GPL, for analysis only)
│   └── app/               # Modernized WordPress fork
│
├── admin/                 # Admin panel
│   ├── api/               # REST API endpoints
│   ├── boot.php           # Bootstrap
│   └── vendor/            # Composer dependencies
│
├── data/                  # User data
│   ├── plugins/           # Installed plugins
│   ├── themes/            # Themes
│   └── uploads/           # Media uploads
│
├── tools/                 # CLI tools
│   ├── plugin-analyzer/   # WordPress plugin analyzer
│   └── plugin-generator/  # Minnow plugin generator
│
└── frontend/              # Frontend templates

Plugin Manifest Format

Plugins are defined using plugin.yaml:

name: my-plugin
version: 1.0.0
description: A sample Minnow plugin
author: Your Name
license: MIT
requires: ">=1.0"

database:
  tables:
    - name: my_items
      columns:
        - { name: id, type: bigint, auto_increment: true }
        - { name: title, type: varchar(255) }
        - { name: status, type: varchar(20), default: draft }
        - { name: created_at, type: datetime }
      primary_key: id
      indexes:
        - { columns: [status], name: idx_status }

entities:
  Item:
    table: my_items
    timestamps: true
    properties:
      id: { type: int, primary: true }
      title: { type: string }
      status: { type: string, default: draft }

api:
  namespace: my-plugin/v1
  routes:
    - path: /items
      entity: Item
      operations: [list, get, create, update, delete]

admin:
  menu:
    - slug: my-plugin
      title: My Plugin
      icon: box
      capability: manage_options
  pages:
    - slug: my-plugin
      title: Items
      type: list
      entity: Item
      columns: [id, title, status, created_at]

frontend:
  shortcodes:
    - tag: my_items
      attributes:
        limit: { type: int, default: 10 }
      handler: Shortcodes\MyItems::render

hooks:
  events:
    - event: entity.item.created
      handler: Handlers\ItemCreated::handle
  filters:
    - filter: item.title
      handler: Filters\SanitizeTitle::apply

Plugin Implementation Status

Minnow plugins go through distinct phases of maturity, tracked via the implementation block in plugin.yaml. This versioning distinguishes between auto-generated plugins and those that have been manually enhanced.

Status Levels

Status Badge Description
generated Yellow Plugin was auto-generated from WordPress analysis. Skeleton code exists but may have issues or missing functionality.
enhanced Blue Plugin has been reviewed and improved by developers/AI. Controllers, entities, and admin pages are functional but may not cover all features.
production Green Plugin is fully tested, feature-complete, and ready for production use.

Implementation Block

Add an implementation block to your plugin manifest to track status:

plugin:
  name: 'Gravity Forms'
  version: 2.9.24
  description: 'Form builder and entry management'
  implementation:
    status: enhanced              # generated | enhanced | production
    minnow_version: 1.0.0
    generated_date: '2024-02-01'
    enhanced_date: '2024-02-04'
    coverage:
      entities: 100%              # Entity classes implemented
      api_endpoints: 95%          # REST API coverage
      admin_pages: 85%            # Admin UI coverage
      frontend: 10%               # Frontend rendering coverage
    enhancements:
      - Controllers migrated from $wpdb to Minnow Connection API
      - Entities configured with proper table prefix
      - Form builder with save/create functionality
    missing:
      - Frontend form rendering
      - Payment gateway integrations
      - Email notification sending

Coverage Tracking

The coverage section provides granular visibility into what percentage of the original plugin's functionality has been implemented:

  • entities: How many data models are implemented and working
  • api_endpoints: REST API routes that are functional
  • admin_pages: Admin interface pages/components completed
  • frontend: Public-facing rendering and interactions

Generated vs Enhanced

When a plugin is first analyzed and generated:

  1. Generated status indicates auto-generated code that needs review
  2. The analyzer extracts structure but not business logic
  3. Controllers may reference WordPress APIs ($wpdb) that don't exist in Minnow

After manual enhancement:

  1. Enhanced status indicates human/AI-improved code
  2. Controllers use Minnow's Connection API instead of WordPress
  3. Admin pages are configured with proper columns, actions, and routes
  4. Missing functionality is documented for future work

Why Track This?

  1. Expectations - Users know what to expect from a generated vs production plugin
  2. Prioritization - Teams can identify which plugins need enhancement work
  3. Documentation - The enhancements and missing arrays serve as a changelog and roadmap
  4. Migration planning - Coverage percentages help estimate remaining work

License

Minnow core (/core, /admin, /tools) is MIT licensed.

The bridge module (/bridge) contains GPL-licensed code derived from WordPress and is used only for analysis purposes during development. It is not required at runtime for a standalone Minnow installation.


Development Status

See minn.xyz/docs/ for current progress.

Component Status
Hook System ✅ Complete
Database Layer ✅ Complete
Entity System ✅ Complete
Authentication ✅ Complete
REST API Planned
Vue Admin Planned
Plugin System In Progress

About

The Minnow Framework

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages