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.
- 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
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)
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
The analyzer extracts structural information from WordPress plugins:
./tools/plugin-analyzer/bin/analyze --plugin=/path/to/gravityformsOutput:
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
Edit plugin.yaml to improve the generated blueprint:
- Review extracted schemas for accuracy
- Add missing fields or relationships
- Define API response transformations
- Specify admin UI layouts
- Set
implementation.statustoenhanced
plugin:
name: 'Gravity Forms'
version: 2.9.24
implementation:
status: enhanced # Protects from accidental regenerationThe generator creates Minnow plugin code from the manifest:
./tools/plugin-generator/bin/generate --plugin=gravityformsProtection: 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
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.yamlThis shows new tables, endpoints, or fields that may need to be incorporated into your enhanced version.
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.
The hook system provides event-driven programming with events (actions) and filters.
use function Minnow\Core\Hook\{on, emit, onFilter, filter};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 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);// 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')) { ... }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', ...);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};// 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_'
);// 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// 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]);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
});// 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');Active Record-style ORM for database models with WordPress table compatibility.
use Minnow\Core\Entity\{Post, User, Comment};// 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');// 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();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();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;
}
}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();User authentication with WordPress database compatibility.
use function Minnow\Core\Auth\{
auth, user, authenticated, guest, can, hasRole, isAdmin,
attempt, logout, hashPassword, verifyPassword
};// 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();// 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
}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 = 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();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
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::applyMinnow 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 | 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. |
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 sendingThe 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
When a plugin is first analyzed and generated:
- Generated status indicates auto-generated code that needs review
- The analyzer extracts structure but not business logic
- Controllers may reference WordPress APIs (
$wpdb) that don't exist in Minnow
After manual enhancement:
- Enhanced status indicates human/AI-improved code
- Controllers use Minnow's
ConnectionAPI instead of WordPress - Admin pages are configured with proper columns, actions, and routes
- Missing functionality is documented for future work
- Expectations - Users know what to expect from a generated vs production plugin
- Prioritization - Teams can identify which plugins need enhancement work
- Documentation - The
enhancementsandmissingarrays serve as a changelog and roadmap - Migration planning - Coverage percentages help estimate remaining work
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.
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 |