Skip to content

shravanjbp/flexi-orm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 

Repository files navigation

Flexi ORM

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.

About

Lightweight Active Record ORM for WordPress

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages