shravanjbp/flexi-orm

Lightweight Active Record ORM for WordPress and PHP

Maintainers

Package info

github.com/shravanjbp/flexi-orm

pkg:composer/shravanjbp/flexi-orm

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

1.0.1 2026-03-26 10:42 UTC

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

  1. Always use eager loading - Use ->with() to prevent N+1 queries
  2. Define relations carefully - Use proper foreign keys and class references
  3. Use fillable/guarded - Protect against mass-assignment vulnerabilities
  4. Cast appropriately - Use casts for JSON, dates, and money to ensure type safety
  5. Hide sensitive data - Use hidden array to exclude passwords, tokens, etc.
  6. Use transactions for complex operations (handle at application level with WordPress)
  7. Index foreign keys - Ensure database performance for relations
  8. 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.