shravanjbp / flexi-orm
Lightweight Active Record ORM for WordPress and PHP
1.0.1
2026-03-26 10:42 UTC
Requires
- php: >=8.1
This package is not auto-updated.
Last update: 2026-03-26 10:44:45 UTC
README
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
Features
- ✅ 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
Installation
composer require shravanjbp/flexi-orm
Quick Start
Basic Model Definition
<?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); } }
Query Operations
Retrieving Models
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();
Building Queries
// 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();
Aggregations
$count = Post::where('status', 'published')->count(); $max = Post::max('views'); $min = Post::min('views'); $sum = Post::sum('views'); $avg = Post::avg('views');
Pagination
// 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);
Creating and Updating Models
Creating
// 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();
Updating
$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
Deleting
$post = Post::find(1); $post->delete(); // Delete multiple Post::where('status', 'draft') ->get() ->each(fn($post) => $post->delete());
Relations
Lazy Loading
$post = Post::find(1); // Automatically loads the relation $author = $post->author; // Single user $comments = $post->comments; // Collection of comments
Eager Loading (N+1 Prevention)
// 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();
Defining Relations
// 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 ); }
Attribute Casting and Accessors/Mutators
Built-in Casts
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 ];
Custom Accessors
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 title
Custom Mutators
class 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 ]);
Serialization
$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'];
Advanced Features
Change Tracking
$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
Refresh from Database
$post = Post::find(1); $post->title = 'Changed locally'; // Reload from database, discarding changes $post->refresh(); $post->title; // Original value from database
Force Fill
// Mass assignment respects fillable/guarded $post->fill(['id' => 999]); // Ignored (guarded) // Bypass protection (use carefully) $post->forceFill(['id' => 999]); // Set directly
Raw Queries
// 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')
Collection Methods
$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);
Configuration
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); // Cents
Error Handling
use 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 }
Best Practices
- 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
Testing
// Set test database instance use Flexi\ORM\Database\Connection; $testWpdb = new \wpdb(...); // Your test database Connection::setInstance($testWpdb); // Run tests... // Reset Connection::reset();
License
GPL-3.0 License. See LICENSE file for details.
Support
For issues and feature requests, refer to the project documentation.