Model

Overview

The Model class is a lightweight base for database table access. Extend it in your own class, set the table name, and instantly get a clean static API for reads and writes — no configuration, no boilerplate.

Under the hood each method creates a fresh QueryBuilder scoped to the model's table and connection. Because where() returns the QueryBuilder directly, you can chain the full query builder API onto any Model method.

  • Class: JiFramework\Core\Database\Model
  • Namespace: JiFramework\Core\Database
  • Usage: extend in your own model class — never instantiate directly
  • Connection: uses Config::$primaryDatabase by default; override per model
use JiFramework\Core\Database\Model;

class User extends Model
{
    protected static string $table = 'users';
}

// Read
$users  = User::all();
$user   = User::find(1);
$count  = User::count();
$exists = User::exists(5);

// Write
$id = User::create(['name' => 'Alice', 'email' => '[email protected]']);
User::update(['name' => 'Alice Smith'], $id);
User::destroy($id);

// Chain QueryBuilder
$active = User::where('status', 'active')
    ->orderBy('name')
    ->limit(10)
    ->get();

Creating a Model

Create a PHP file for your model, extend Model, and set the static $table property. That is the minimum required — everything else has sensible defaults.

Minimum setup
<?php
use JiFramework\Core\Database\Model;

class Post extends Model
{
    protected static string $table = 'posts';
}
Available static properties
  • $table(string, required) The database table name.
  • $primaryKey(string, default: 'id') The primary key column. Override when your table uses a different column.
  • $connection(string, default: 'primary') The named database connection to use. Matches a key in Config::$databases.
Custom primary key
class Product extends Model
{
    protected static string $table      = 'products';
    protected static string $primaryKey = 'sku';     // primary key is 'sku', not 'id'
}

$product = Product::find('ABC-001');
Product::destroy('ABC-001');
Multiple database connections
use JiFramework\Core\Database\Model;

class Order extends Model
{
    protected static string $table      = 'orders';
    protected static string $connection = 'shop'; // uses Config::$databases['shop']
}
Where to put model files

Place your model files inside a models/ folder at your project root. The framework loads every .php file in that folder automatically when new App() runs — no require, no Composer changes, no configuration needed.

project/
├── models/
│   ├── User.php       // loaded automatically
│   ├── Post.php       // loaded automatically
│   └── Product.php    // loaded automatically
├── index.php
└── config/
    └── jiconfig.php
// index.php
$app = new App(); // models/ is scanned and loaded here

// All model classes are immediately available
$users    = User::all();
$posts    = Post::where('status', 'published')->get();
$product  = Product::find(1);

Only *.php files directly inside models/ are loaded. Sub-folders are not scanned. Keep one model class per file.

all()

static all(): array

Fetch every row from the table. Returns an array of associative arrays.

Use this for small reference tables (categories, settings, country lists). For larger tables prefer where() with filters or paginate().

$users = User::all();

foreach ($users as $user) {
    echo $user['name'] . ' — ' . $user['email'];
}

Returns an empty array when the table has no rows.

find()

static find(mixed $id): ?array

Fetch a single row by primary key. Returns the row as an associative array, or null if no row has that key.

  • $id(mixed) The primary key value. Works with integer IDs and string keys alike.
$user = User::find(1);

if ($user === null) {
    $app->abort(404, 'User not found');
}

echo $user['name'];
With a custom primary key
class Product extends Model
{
    protected static string $table      = 'products';
    protected static string $primaryKey = 'sku';
}

$product = Product::find('ABC-001'); // WHERE sku = 'ABC-001'

Always check for null before accessing the returned array. find() never throws — it returns null for missing rows.

where()

static where(string $column, mixed $operatorOrValue, mixed $value = null): QueryBuilder

Start a filtered query. Returns a QueryBuilder instance scoped to the model's table, so you can chain any QueryBuilder method (get(), first(), count(), orderBy(), limit(), pluck(), etc.).

Two-argument shorthand (assumes =)
$active = User::where('status', 'active')->get();
Three-argument with explicit operator
$adults  = User::where('age', '>=', 18)->get();
$recent  = User::where('created_at', '>', '2024-01-01')->get();
$premium = User::where('plan', '!=', 'free')->get();
Chaining additional conditions
$results = User::where('status', 'active')
    ->where('age', '>=', 18)
    ->orderBy('name')
    ->limit(25)
    ->get();
Using OR conditions
$results = User::where('role', 'admin')
    ->orWhere('role', 'moderator')
    ->get();
Getting a single result
$user = User::where('email', '[email protected]')->first();
Counting
$total = User::where('status', 'active')->count();

first()

static first(): ?array

Fetch the first row from the table (no filters). Returns the row as an associative array, or null if the table is empty.

For filtered first rows, use where()->first() instead.

// First row in the table (by insertion order / table default)
$first = User::first();

// First matching a condition -- use where()->first()
$admin = User::where('role', 'admin')->orderBy('created_at')->first();

if ($admin === null) {
    echo 'No admin found';
}

count()

static count(): int

Count all rows in the table. Returns an integer.

For a filtered count, use where()->count() instead.

// Total users
$total = User::count();

// Active users only
$active = User::where('status', 'active')->count();

// Users registered this month
$thisMonth = User::where('created_at', '>=', date('Y-m-01'))->count();

insert()

static insert(array $data): bool

Insert a new row. Returns true on success. Use this when you do not need the new row's ID. If you need the ID, use create() instead.

  • $data(array) Associative array of column names and values.
User::insert([
    'name'       => 'Alice',
    'email'      => '[email protected]',
    'status'     => 'active',
    'created_at' => date('Y-m-d H:i:s'),
]);
Inserting multiple rows inside a transaction
$app->db->beginTransaction();
try {
    foreach ($rows as $row) {
        User::insert($row);
    }
    $app->db->commit();
} catch (Throwable $e) {
    $app->db->rollBack();
    throw $e;
}

create()

static create(array $data): int|false

Insert a new row and return the new primary key as an integer. Returns false if the insert fails.

Use create() instead of insert() whenever you need the ID of the newly created row — for example, to associate related records or redirect to a detail page.

  • $data(array) Associative array of column names and values.
$id = User::create([
    'name'       => 'Bob',
    'email'      => '[email protected]',
    'status'     => 'active',
    'created_at' => date('Y-m-d H:i:s'),
]);

if ($id === false) {
    $app->abort(500, 'Could not create user');
}

// Use the new ID immediately
$profile = Profile::create(['user_id' => $id, 'bio' => '']);
header('Location: /users/' . $id);

create() works with any auto-increment column. For tables with non-auto-increment primary keys use insert() instead.

update()

static update(array $data, mixed $id): bool

Update a row by primary key. Returns true if one or more rows were affected, false if no row matched the given ID.

  • $data(array) Associative array of columns to update and their new values.
  • $id(mixed) The primary key value of the row to update.
// Update a single column
User::update(['status' => 'inactive'], 42);

// Update multiple columns at once
User::update([
    'name'       => 'Alice Smith',
    'email'      => '[email protected]',
    'updated_at' => date('Y-m-d H:i:s'),
], 42);

// Check whether a row was actually found and updated
$updated = User::update(['status' => 'banned'], 999);
if (!$updated) {
    echo 'User not found';
}
Updating by a condition (not PK)

Use where()->update() on the QueryBuilder for condition-based updates:

// Deactivate all trial users whose trial has expired
User::where('plan', 'trial')
    ->where('trial_ends_at', '<', date('Y-m-d H:i:s'))
    ->update(['status' => 'expired']);

destroy()

static destroy(mixed $id): bool

Delete a row by primary key. Returns true if the row was found and deleted, false if no row matched.

  • $id(mixed) The primary key value of the row to delete.
$deleted = User::destroy(42);

if (!$deleted) {
    $app->abort(404, 'User not found');
}
Deleting by a condition (not PK)

Use where()->delete() on the QueryBuilder to delete rows matching a condition:

// Delete all unverified accounts older than 7 days
User::where('verified', 0)
    ->where('created_at', '<', date('Y-m-d', strtotime('-7 days')))
    ->delete();

exists()

static exists(mixed $id): bool

Check whether a row with the given primary key exists. Returns true or false. More expressive than find() !== null — and more efficient because it runs a COUNT query rather than fetching the full row.

  • $id(mixed) The primary key value to check.
if (!User::exists($id)) {
    $app->abort(404, 'User not found');
}

// Validate a foreign key before inserting
if (!Category::exists($categoryId)) {
    $app->abort(422, 'Invalid category');
}
Existence check by condition

For existence checks that are not by primary key, use where()->exists() on the QueryBuilder:

// Check if an email is already taken
$taken = User::where('email', '[email protected]')->exists();

if ($taken) {
    echo 'Email already registered';
}

Chaining QueryBuilder

where() returns a QueryBuilder instance scoped to the model's table. This means the full QueryBuilder API is available after any where() call — ordering, limiting, joins, aggregates, pagination, and more.

Ordering and limiting
$latest = User::where('status', 'active')
    ->orderBy('created_at', 'DESC')
    ->limit(10)
    ->get();
Plucking a single column
$emails = User::where('status', 'active')->pluck('email');
// ['[email protected]', '[email protected]', ...]
Aggregates
$avg = User::where('status', 'active')->avg('age');
$max = User::where('plan', 'premium')->max('created_at');
$sum = Order::where('user_id', 1)->sum('total');
Pagination
$page = User::where('status', 'active')
    ->orderBy('name')
    ->paginate(20);

// $page->data, $page->totalItems, $page->totalPages, etc.
$links = $app->paginator->renderLinks($page, '/users');
Joins
$posts = Post::where('published', 1)
    ->leftJoin('users', 'posts.user_id', '=', 'users.id')
    ->orderBy('posts.created_at', 'DESC')
    ->select(['posts.*', 'users.name AS author'])
    ->get();
whereIn
$users = User::where('status', 'active')
    ->whereIn('role', ['admin', 'moderator'])
    ->get();
Conditional query building
$status = $_GET['status'] ?? null;

$users = User::where('verified', 1)
    ->when($status, fn($q) => $q->where('status', $status))
    ->orderBy('name')
    ->get();

Adding Custom Methods

Because models are plain PHP classes, you can add any static methods you need directly in your model file. This is the recommended way to encapsulate query logic — keeping controllers thin and business logic reusable.

Simple lookups and filters
class User extends Model
{
    protected static string $table = 'users';

    public static function findByEmail(string $email): ?array
    {
        return static::where('email', $email)->first();
    }

    public static function active(): array
    {
        return static::where('status', 'active')->orderBy('name')->get();
    }

    public static function countByStatus(string $status): int
    {
        return static::where('status', $status)->count();
    }
}
Complex query with join and multiple conditions
class Post extends Model
{
    protected static string $table = 'posts';

    // Fetch published posts with author name and comment count
    public static function published(int $limit = 10): array
    {
        return static::where('posts.status', 'published')
            ->leftJoin('users',    'posts.user_id',  '=', 'users.id')
            ->leftJoin('comments', 'comments.post_id', '=', 'posts.id')
            ->select([
                'posts.id',
                'posts.title',
                'posts.slug',
                'posts.created_at',
                'users.name AS author',
                'COUNT(comments.id) AS comment_count',
            ])
            ->groupBy('posts.id')
            ->orderBy('posts.created_at', 'DESC')
            ->limit($limit)
            ->get();
    }
}
Search method with optional filters
class Product extends Model
{
    protected static string $table = 'products';

    // Every parameter is optional -- combine any subset
    public static function search(
        ?string $keyword  = null,
        ?string $category = null,
        ?float  $maxPrice = null,
        string  $sort     = 'name',
        int     $perPage  = 20
    ): object {
        $query = static::where('status', 'active');

        if ($keyword !== null) {
            $query->whereRaw(
                '(name LIKE :kw OR description LIKE :kw2)',
                ['kw' => '%' . $keyword . '%', 'kw2' => '%' . $keyword . '%']
            );
        }

        if ($category !== null) {
            $query->where('category', $category);
        }

        if ($maxPrice !== null) {
            $query->where('price', '<=', $maxPrice);
        }

        return $query->orderBy($sort)->paginate($perPage);
    }
}

// Usage
$page = Product::search(keyword: 'phone', maxPrice: 500, perPage: 15);
Transaction: create related records atomically
class User extends Model
{
    protected static string $table = 'users';

    // Create user + profile in one transaction
    public static function register(array $data): int
    {
        $db = \JiFramework\Core\App\App::getInstance()->db;
        $db->beginTransaction();

        try {
            $userId = static::create([
                'name'       => $data['name'],
                'email'      => $data['email'],
                'password'   => password_hash($data['password'], PASSWORD_BCRYPT),
                'status'     => 'active',
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            Profile::create([
                'user_id'    => $userId,
                'bio'        => '',
                'avatar'     => 'default.png',
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            $db->commit();
            return $userId;

        } catch (Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }
}

// Usage
$userId = User::register($_POST);
header('Location: /dashboard');
Raw SQL for complex aggregation
class Order extends Model
{
    protected static string $table = 'orders';

    // Revenue grouped by month for the current year
    public static function revenueByMonth(int $year): array
    {
        return static::where('status', 'completed')
            ->whereRaw('YEAR(created_at) = :yr', ['yr' => $year])
            ->selectRaw('MONTH(created_at) AS month, SUM(total) AS revenue, COUNT(*) AS orders')
            ->groupBy('month')
            ->orderBy('month')
            ->get();
    }

    // Top customers by total spend
    public static function topCustomers(int $limit = 10): array
    {
        return static::where('status', 'completed')
            ->leftJoin('users', 'orders.user_id', '=', 'users.id')
            ->selectRaw('users.id, users.name, users.email, SUM(orders.total) AS total_spent, COUNT(orders.id) AS order_count')
            ->groupBy('users.id')
            ->orderBy('total_spent', 'DESC')
            ->limit($limit)
            ->get();
    }
}
Automatic timestamps by overriding create() and update()
class Post extends Model
{
    protected static string $table = 'posts';

    public static function create(array $data): int|false
    {
        $data['created_at'] = date('Y-m-d H:i:s');
        $data['updated_at'] = date('Y-m-d H:i:s');
        return parent::create($data);
    }

    public static function update(array $data, $id): bool
    {
        $data['updated_at'] = date('Y-m-d H:i:s');
        return parent::update($data, $id);
    }
}

The base Model stays lean. Each model adds only what it needs. Complex logic lives in the model — not in the controller or page file.

Examples

Complete real-world examples.

User model with custom methods
<?php
use JiFramework\Core\Database\Model;

class User extends Model
{
    protected static string $table = 'users';

    public static function findByEmail(string $email): ?array
    {
        return static::where('email', $email)->first();
    }

    public static function active(): array
    {
        return static::where('status', 'active')->orderBy('name')->get();
    }

    public static function create(array $data): int|false
    {
        $data['created_at'] = date('Y-m-d H:i:s');
        return parent::create($data);
    }
}
Registration handler
<?php
// pages/register.php
$email = trim($_POST['email'] ?? '');
$name  = trim($_POST['name']  ?? '');

// Check duplicate
if (User::where('email', $email)->exists()) {
    $app->abort(422, 'Email already registered');
}

$id = User::create([
    'name'     => $name,
    'email'    => $email,
    'password' => password_hash($_POST['password'], PASSWORD_BCRYPT),
    'status'   => 'active',
]);

header('Location: /dashboard');
Profile update handler
<?php
// pages/profile_update.php
$userId = $app->session->get('user_id');

if (!User::exists($userId)) {
    $app->abort(404, 'User not found');
}

User::update([
    'name'       => trim($_POST['name'] ?? ''),
    'bio'        => trim($_POST['bio']  ?? ''),
    'updated_at' => date('Y-m-d H:i:s'),
], $userId);

header('Location: /profile');
Admin user list with pagination
<?php
// pages/admin/users.php
$filter = $_GET['status'] ?? null;

$page = User::where('verified', 1)
    ->when($filter, fn($q) => $q->where('status', $filter))
    ->orderBy('created_at', 'DESC')
    ->paginate(25);

$links = $app->paginator->renderLinks($page, '/admin/users');
Multi-connection model
<?php
use JiFramework\Core\Database\Model;

// Reads and writes to the 'analytics' connection defined in jiconfig.php
class Event extends Model
{
    protected static string $table      = 'events';
    protected static string $connection = 'analytics';
}

Event::create([
    'user_id'    => $userId,
    'event_type' => 'page_view',
    'url'        => $app->url->current(),
    'created_at' => date('Y-m-d H:i:s'),
]);