A lightweight, production-ready Active Record ORM for WordPress. Provides a clean, expressive API for database operations without direct SQL.
Requires: PHP 8.1+, WordPress with $wpdb available
- âś… Active Record Pattern - Models representing database tables
- âś… Type-Safe - Full PHPDoc and type hints for IDE support
- âś… Relationships - HasMany, HasOne, BelongsTo, ModuleMany (polymorphic)
- âś… Eager Loading - Prevent N+1 queries with relation loading
- âś… Fluent Query Builder - Expressive method chaining
- âś… Dirty Tracking - Track changed attributes before saving
- âś… Mass Assignment Protection - Fillable/guarded configuration
- âś… Attribute Casting - Automatic JSON, datetime, money conversions
- âś… Serialization - Hidden/visible attributes for API responses
- âś… Pagination - Offset-based pagination for REST APIs
- âś… Comprehensive Documentation - Over 200 PHPDoc comments
composer require shravanjbp/flexi-orm<?php
namespace App\Models;
use Flexi\ORM\Core\Model;
use Flexi\ORM\Relations\HasMany;
use Flexi\ORM\Relations\BelongsTo;
class Post extends Model
{
// Table name (without prefix)
protected string $table = 'posts';
// Primary key column
protected string $primaryKey = 'id';
// Mass-assignable attributes
protected array $fillable = [
'title',
'content',
'status',
'author_id',
'featured_image_id',
];
// Protected attributes (cannot be mass-assigned)
protected array $guarded = ['id', 'created_at', 'updated_at'];
// Attribute casting
protected array $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'views' => 'integer',
'meta' => 'json',
'price' => 'money', // Stored as cents, cast to Money object
];
// Hide attributes from JSON serialization
protected array $hidden = [
'update_token',
];
// Override with whitelist of visible attributes
// protected array $visible = ['id', 'title', 'status'];
// Define relations
protected function author(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}use App\Models\Post;
// Get all posts
$posts = Post::all();
// Get first post
$post = Post::first();
// Find by primary key
$post = Post::find(1);
// Find or throw exception
$post = Post::findOrFail(1);
// Find multiple by IDs
$posts = Post::findMany([1, 2, 3]);
// Check if any records exist
if (Post::exists()) {
// ...
}
// Count records
$count = Post::count();// Simple where clause
Post::where('status', 'published')->get();
// Where with operator
Post::where('views', '>', 100)->get();
// Multiple conditions (AND)
Post::where('status', 'published')
->where('author_id', 5)
->get();
// OR conditions
Post::where('status', 'draft')
->orWhere('status', 'scheduled')
->get();
// IN clause
Post::whereIn('status', ['published', 'draft'])->get();
// NOT IN clause
Post::whereNotIn('author_id', [1, 2, 3])->get();
// BETWEEN
Post::whereBetween('views', 100, 1000)->get();
// NULL checks
Post::whereNull('deleted_at')->get();
Post::whereNotNull('published_at')->get();
// Raw where clause (use with caution)
Post::whereRaw('DATE(created_at) = CURDATE()')->get();
// JOINs
Post::join('users', 'posts.author_id', '=', 'users.id')
->select('posts.*', 'users.name')
->get();
Post::leftJoin('comments', 'posts.id', '=', 'comments.post_id')->get();
// Ordering
Post::orderBy('created_at', 'DESC')->get();
Post::latest('created_at')->get(); // DESC
Post::oldest('created_at')->get(); // ASC
// Limit and Offset
Post::limit(10)->get();
Post::skip(10)->take(10)->get();
// SELECT specific columns
Post::select('id', 'title', 'status')->get();
// Group By
Post::select('author_id')
->selectRaw('COUNT(*) as post_count')
->groupBy('author_id')
->get();$count = Post::where('status', 'published')->count();
$max = Post::max('views');
$min = Post::min('views');
$sum = Post::sum('views');
$avg = Post::avg('views');// Get page 2, 15 items per page
$paginator = Post::paginate(15, 2);
$paginator->items(); // Collection of posts
$paginator->total(); // Total number of records
$paginator->perPage(); // Items per page
$paginator->currentPage(); // Current page number
$paginator->lastPage(); // Last page number
$paginator->hasMorePages(); // true/false
// JSON response (automatically includes meta)
json_encode($paginator);// Create unsaved instance
$post = Post::make([
'title' => 'Hello World',
'status' => 'draft',
]);
// Create and save
$post = Post::create([
'title' => 'Hello World',
'content' => 'Lorem ipsum...',
'status' => 'published',
'author_id' => 5,
]);
// Mass assign and save
$post = new Post();
$post->fill([
'title' => 'Hello',
'content' => 'World',
]);
$post->save();$post = Post::find(1);
// Update single attribute
$post->title = 'Updated Title';
$post->save();
// Update multiple attributes
$post->update([
'title' => 'New Title',
'status' => 'published',
]);
// Check what changed
$post->isDirty(); // true/false
$post->isDirty('title'); // true/false
$post->getDirty(); // ['title' => 'New Title']
$post->getOriginal(); // Original attribute values
$post->getOriginal('title');// Original title value$post = Post::find(1);
$post->delete();
// Delete multiple
Post::where('status', 'draft')
->get()
->each(fn($post) => $post->delete());$post = Post::find(1);
// Automatically loads the relation
$author = $post->author; // Single user
$comments = $post->comments; // Collection of comments// Bad - N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Query for each post
}
// Good - 2 queries total
$posts = Post::with('author')
->with('comments')
->get();
// Alternative syntax
$posts = Post::query()
->with(['author', 'comments'])
->get();
// Nested relations
$posts = Post::with('author', 'comments.author')->get();
// Eager load with constraints
$posts = Post::query()
->with('comments', function ($query) {
$query->where('status', 'approved')
->orderBy('created_at', 'DESC');
})
->get();// One-to-Many
protected function comments(): HasMany
{
return $this->hasMany(Comment::class);
// Assumes 'post_id' foreign key on comments table
}
// One-to-One
protected function featuredImage(): HasOne
{
return $this->hasOne(Image::class, 'image_id');
}
// Many-to-One (Inverse)
protected function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id', 'id');
}
// Polymorphic (AttachmentModule model can belong to multiple types)
protected function attachments(): ModuleMany
{
return $this->moduleMany(
Attachment::class,
'post', // The type value to filter by
'module_type', // Column name storing type
'module_id' // Column name storing ID
);
}protected array $casts = [
'id' => 'integer',
'views' => 'int',
'price' => 'float',
'is_published' => 'boolean',
'tags' => 'array', // Parse JSON or serialized
'meta' => 'json', // Parse JSON only
'created_at' => 'datetime', // Parse as DateTimeImmutable
'published_date' => 'date', // Parse as date (time set to 00:00)
'amount' => 'money', // Store cents, get Money object
];class Post extends Model
{
// Access $post->title_slug -> automatically calls this
protected function getTitleSlugAttribute(): string
{
return str_replace(' ', '-', strtolower($this->title));
}
// Access $post->full_title
protected function getFullTitleAttribute(): string
{
return $this->title . ' - ' . $this->status;
}
}
$post = Post::find(1);
$post->title_slug; // Auto-converts title to slug
$post->full_title; // Auto-generates full titleclass Post extends Model
{
// When setting $post->title = 'x', this is called
protected function setTitleAttribute(string $value): void
{
// Trim whitespace
$this->attributes['title'] = trim($value);
}
protected function setSlugAttribute(string $value): void
{
// Automatically slugify
$this->attributes['slug'] = str_replace(' ', '-', strtolower($value));
}
}
$post = Post::make([
'title' => ' Hello World ', // Automatically trimmed
'slug' => 'Hello World', // Automatically becomes hello-world
]);$post = Post::with('author', 'comments')->find(1);
// To array
$array = $post->toArray();
// To JSON
$json = $post->toJson();
$json = json_encode($post); // JsonSerializable interface
// Hide attributes
$post->makeHidden('content');
$post->toArray(); // Excludes 'content'
// Make hidden attribute visible
$post->makeVisible('content');
// Whitelist visible attributes
protected array $visible = ['id', 'title', 'author', 'created_at'];
// Append computed attributes
protected array $appends = ['title_slug', 'full_title'];$post = Post::find(1);
$post->title = 'New Title';
$post->isDirty(); // true
$post->isDirty('title'); // true
$post->isDirty('content'); // false
$post->getDirty(); // ['title' => 'New Title']
$post->getOriginal('title'); // 'Old Title'
$post->wasChanged('title'); // Alias for isDirty()
$post->save();
$post->isDirty(); // false
$post->isClean(); // true$post = Post::find(1);
$post->title = 'Changed locally';
// Reload from database, discarding changes
$post->refresh();
$post->title; // Original value from database// Mass assignment respects fillable/guarded
$post->fill(['id' => 999]); // Ignored (guarded)
// Bypass protection (use carefully)
$post->forceFill(['id' => 999]); // Set directly// Get raw SQL for debugging
$sql = Post::where('status', 'published')->toSql();
echo $sql; // SELECT * FROM wp_posts WHERE (status = %s)
// Get SQL with bindings replaced
$raw = Post::where('status', 'published')->toRawSql();
echo $raw; // SELECT * FROM wp_posts WHERE (status = 'published')$posts = Post::all(); // Returns Collection
$posts->first(); // First item
$posts->last(); // Last item
$posts->count(); // Count
$posts->isEmpty(); // Check if empty
$posts->isNotEmpty(); // Check if has items
// Iteration
$posts->each(function ($post) {
echo $post->title;
});
// Transformation
$titles = $posts->map(fn($p) => $p->title)->all();
$published = $posts->filter(fn($p) => $p->status === 'published');
$sorted = $posts->sortBy('created_at');
$reversed = $posts->reverse();
// Grouping
$byAuthor = $posts->groupBy('author_id');
$byStatus = $posts->groupBy(fn($p) => $p->status);
// Extraction
$ids = $posts->pluck('id');
$byAuthor = $posts->pluck('title', 'author_id');
// Combining
$union = $posts->merge($other_posts);
$intersection = $posts->intersect($other_posts);
$difference = $posts->diff($other_posts);
// Slicing
$first5 = $posts->take(5);
$skip5 = $posts->skip(5);
$chunk = $posts->chunk(10);use Flexi\ORM\Support\Config;
// Get config
Config::get('timezone'); // 'UTC' (default)
Config::get('pagination.perPage'); // 10 (default)
// Set config
Config::set('timezone', 'America/New_York');
Config::set('money.currency', 'EUR');
Config::set('money.scale', 100); // Centsuse Flexi\ORM\Exceptions\ModelNotFoundException;
use Flexi\ORM\Exceptions\QueryException;
use Flexi\ORM\Exceptions\RelationNotFoundException;
try {
$post = Post::findOrFail(999);
} catch (ModelNotFoundException $e) {
// Handle not found (use for 404 responses)
}
try {
$posts = Post::query()->get();
} catch (QueryException $e) {
// Database error
$sql = $e->getSql();
// Handle database error
}- Always use eager loading - Use
->with()to prevent N+1 queries - Define relations carefully - Use proper foreign keys and class references
- Use fillable/guarded - Protect against mass-assignment vulnerabilities
- Cast appropriately - Use casts for JSON, dates, and money to ensure type safety
- Hide sensitive data - Use
hiddenarray to exclude passwords, tokens, etc. - Use transactions for complex operations (handle at application level with WordPress)
- Index foreign keys - Ensure database performance for relations
- Validate before saving - Validate data before persistence
// Set test database instance
use Flexi\ORM\Database\Connection;
$testWpdb = new \wpdb(...); // Your test database
Connection::setInstance($testWpdb);
// Run tests...
// Reset
Connection::reset();GPL-3.0 License. See LICENSE file for details.
For issues and feature requests, refer to the project documentation.