Models

ORM Model Overview

Introduction to ORM Models

Lightpack ORM is an Active Record pattern implementation. Each model is a class directly corresponds to a single table in your database, and each instance of a model represents a single row within that table. The model not only holds data, but also encapsulates all the logic required to create, read, update, and delete (CRUD) records.

Key Fundamentals of Active Record:

The following sections will explore how to define models, establish relationships, and utilize the full capabilities of the ORM system.


Consider that you have a products table in your database.

Table: products
-------------------------------------------------
id, name, size, color, status
-------------------------------------------------

Then you should define a Product model in app/Models folder.

Defining Model

Fire this command to generate a model from your terminal inside your project root.

php console create:model Product --table=products

This should have created Product model in app/Models folder.

class Product extends Model
{
    protected $table = 'products';

    protected $primaryKey = 'id';

    protected $timestamps = false;
}

Defining your model in this manner gives you access to a number of utility methods to deal with records in your products table.

Performing CRUD

Once the model class is defined, performing CRUD operations per single database record becomes very easy. You do not need to manually wire-up raw SQL queries for:

Create

Set properties on your new model and simply call the inherited method insert() to insert a new record.

// Create the instance
$product = new Product;

// Set product properties
$product->name = 'ACME Shoes';
$product->size = 10;
$product->color = 'black';

// Create new product
$product->insert();

Last Insert ID

If your table's primary key is an auto-incrementing field, you can get the last insert id:

$product->lastInsertId();

Manual Primary Key

If your table's primary key is a non auto-incrementing field, you must override the inherited model attribute autoIncrements to false.

class Product extends Model
{
    protected $autoIncrements = false;
}

Now when calling insert() method, you must set a unique primary key value before inserting the new record.

// Create the instance
$product = new Product;

// Set product properties including manual primary key
$product->id = 'sku1000';
$product->name = 'ACME Shoes';
$product->size = 10;
$product->color = 'black';

// Create new product
$product->insert();

Read

You can easily fetch a record by its ID when constructing the model.

$product = new Product(23);

Now you can access the column values as model properties.

echo $product->name;
echo $product->size;
echo $product->color;

Update

Use the update() method to update an existing record in the database table. You first need to instantiate the model using the primary key of the table.

// Get an existing product having id: 23
$product = new Product(23);

// Set product properties to update
$product->name = 'ACME Footwear';
$product->size = 11;
$product->color = 'brown';

// Update the product
$product->update();

Delete

Simply call the delete() passing it the id of the record to be deleted from database.

(new Product)->delete(23);

If you already have an existing instance of model, you can call delete() method there too.

$product = new Product(23);
$product->delete(); // passing id not required

Save

The save() method provides a convenient way to persist model changes without worrying whether you're creating or updating a record:

// Creating a new record
$product = new Product;
$product->name = 'ACME Shoes';
$product->size = 10;
$product->save(); // Inserts new record

echo $product->id; // Auto-generated ID is now available
// Updating an existing record
$product = new Product(23);
$product->name = 'Updated Name';
$product->save(); // Updates existing record

Note: The save() method is most reliable for models with auto-incrementing primary keys. For non-auto-increment primary keys, use insert() and update() explicitly.

Timestamps

Consider products table:

Table: products
-------------------------------------------------
id, name, price, created_at, updated_at
-------------------------------------------------

Setting $timestamps property to true automatically sets values for created_at and updated_at columns in your table. So when you create a new product or update an existing product, you don't have to manually set values for these columns.

In order for timestamps to work, the table must have created_at and updated_at columns both.

Refetch

Sometimes, the data in your existing model instance can become outdated—especially if changes are made to the database from somewhere else in your application.

For an example, consider the scenario where the timestamps attribute in model class definition is set to true.

class Product extends Model
{
    protected $timestamps = true;
}

In such case, performing an insert() or update() automatically sets created_at and updated_at columns. This happens as a side-effect of the framework's ORM implementation as convinience.

/**
 * created_at, updated_at column is set automatically
 */
$product->insert(); 

Now if you try this:

echo $product->created_at; // null
echo $product->updated_at; // null

To ensure you are working with the latest data for the current record from the database, you can call refetch() method.

$product = $product->refetch();

$product will contain the latest data. If the record was deleted or the primary key isn’t set, it will return null.

Attribute Casting

Attribute casting converts model attributes to a specific type (like integer, boolean, array, or date) when you access them, and back to a database-friendly format when you save them.

Supported Cast Types

Cast Type Description & Example
int Converts to integer: '123'123
float Converts to float: '123.45'123.45
string Converts to string: 123'123'
bool Converts to boolean: '1', 1, 'true'true
array Converts JSON string to array (stores as JSON in database)
date Converts to DateTime object (stores as Y-m-d in database)
datetime Converts to DateTime object (stores as Y-m-d H:i:s in database)
timestamp Converts to Unix timestamp integer (stores as integer in database)

Example Usage

Suppose your User model has a settings column that stores JSON, and a created_at column for timestamps. You can define casts like this:

class User extends Model
{
    protected $casts = [
        'settings'   => 'array',
        'created_at' => 'datetime',
        'active'     => 'bool',
        'score'      => 'int',
    ];
}

Now, whenever you access $user->settings, you’ll get an array. $user->created_at will be a DateTime object, and so on.

How Casting Works

Common Pitfalls

Custom Casts

When built-in cast types aren't enough, you can create your own custom cast classes. This is useful for domain-specific types like money, coordinates, encrypted data, or value objects.

Creating a Custom Cast

To create a custom cast, implement the CastInterface which requires two methods:

use Lightpack\Database\Lucid\Casts\CastInterface;

class MoneyCast implements CastInterface
{
    public function get(mixed $value): Money
    {
        // Convert database cents to Money object
        return new Money($value);
    }

    public function set(mixed $value): int
    {
        // Convert Money object back to cents for database
        return $value instanceof Money ? $value->toCents() : $value;
    }
}

Using Custom Casts

Once defined, use your custom cast just like built-in types:

class Product extends Model
{
    protected $casts = [
        'price' => MoneyCast::class,  // Custom cast
        'quantity' => 'int',           // Built-in cast
    ];
}

Now your model attributes will be automatically cast:

$product = new Product(1);
echo $product->price->format();     // "$99.99"
echo $product->price->currency();   // "USD"

$product->price = new Money(5000);  // Set as Money object
$product->save();                    // Stored as 5000 cents in database

Null Handling

You don't need to handle null values in your custom casts—the framework does this automatically:

$product->price = null;
$product->save();                    // Stored as NULL
echo $product->price;                // null (not a Money object)

When to Use Custom Casts

Custom casts are ideal when you need to:


Cloning a Model

There may be times when you want to create a new record in your database that’s almost identical to an existing one—without re-entering all the data. The clone method makes this easy: it creates a new model instance with the same attribute values as the original, but leaves out the primary key and timestamps, so you can safely save it as a new record.

This is especially useful for duplicating templates, copying products, or quickly creating similar entries.

How it works:

Example:
Let’s say you want to duplicate a product but change its name:

$original = new Product(23); // Load an existing product
$copy = $original->clone();  // Create a new instance with the same data

$copy->name = 'New Product Name';
$copy->insert(); // Save as a new product in the database

If you want to exclude more fields from being copied, just pass them as an array:

$copy = $original->clone(['description', 'price']);

If you try to clone a model that doesn’t exist in the database, you’ll get an error.

Tracking Unsaved Changes

When working with models, you may want to know if you’ve made changes that haven’t been saved to the database yet. Lightpack models make this easy with two helpful methods:

Method What it does Example Output
isDirty() Checks if the model (or a specific field) has unsaved changes true / false
getDirty() Lists all fields that have unsaved changes ['name', 'email']

Why is this useful?

How to use

Check if anything changed:

$user = new User(1);
$user->name = 'New Name';

if ($user->isDirty()) {
    // There are unsaved changes
}

Check if a specific field changed:

if ($user->isDirty('name')) {
    // The 'name' field was modified
}

See which fields changed:

$dirty = $user->getDirty(); // e.g., ['name', 'email']

Typical scenarios

Example: Send Email Verification When Email Changes

Suppose you want to automatically send an email verification request whenever a user updates their profile and changes their email address. With isDirty('email'), you can easily detect this:

$user = new User(23);
$user->name = 'John Doe';

if ($user->isDirty('email')) { // false
    $user->email_verified_at = null;
}

// Update user changes
$user->update();

if($user->email_verified_at == null) {
    // send verification mail
}

In above example, only user's name was changed, so before saving the profile, $user->isDirty('email') check will be false.This way, you only send the verification request if the email was actually updated—no need to compare values manually!

Once the insert() or update() method is called on the model instance, the ORM clears all the dirty attributes. So isDirty() method returns false and getDirty() method returns empty array after model persistence.

Query Builders

Lightpack models are capable query builders too.

To get a query builder on a model, call the static method query():

$productQuery = Product::query();

Now you can access all the methods on query builders. Below are some example for using query builder on a model.

Fetch all products

$products = Product::query()->all();

Fetch all active products

$products = Product::query()->where('active', '=', '1')->all();

Fetch products with matching ids

$products = Product::query()->whereIn('id', [1,2,3])->all();

Fetch all products with at least one order

$products = Product::query()->has('orders')->all();

The above is same as:

$products = Product::query()->has('orders', '>', 0)->all();
// or
$products = Product::query()->has('orders', '>=', 1)->all();

Fetch products with no orders

$products = Product::query()->doesntHave('orders')->all();

You can also write has('orders', '=', 0) but doesntHave() is more readable.

Fetch products with at least 2 orders

$products = Product::query()->has('orders', '>', 1)->all();
// or
$products = Product::query()->has('orders', '>=', 2)->all();

Fetch products with atmost 2 orders

$products = Product::query()->has('orders', '<', 3)->all();
// or
$products = Product::query()->has('orders', '<=', 2)->all();

Callbacks as query constraints

You can even pass a callback as 4th parameter to has() method to add more constraints on relationship. For example, suppose you want to fetch products with atleast 2 paid orders.

$products = Product::query()->has('orders', '>=', 2, function($q) {
    $q->where('paid', '=', true);
})->all();

whereHas() — constrained existence check

whereHas() is a convenience wrapper around has() for the common case where you want to check for existence with a condition but don't need an operator/count threshold. It reads more naturally when a callback is the primary focus:

// Products that have at least one approved review
$products = Product::query()->whereHas('reviews', function ($q) {
    $q->where('status', '=', 'approved');
})->all();

Use whereHas() when a constraint callback is your only concern. Use has() when you also need an operator and count threshold.

doesntHave() — inverse existence check

Use doesntHave() to fetch parent models that have no related records at all:

// Products with no orders
$products = Product::query()->doesntHave('orders')->all();

whereDoesntHave() — inverse constrained existence check

Use whereDoesntHave() to fetch parent models that have no related records matching a condition:

// Projects with no overdue tasks
$projects = Project::query()->whereDoesntHave('tasks', function ($q) {
    $q->where('due_date', '<', date('Y-m-d'));
})->all();

doesntHave() and whereDoesntHave() are the negative counterparts of has() and whereHas().

Query Filters

Query filters provide a clean way to filter database records using model scopes. They allow you to encapsulate common query constraints and apply them dynamically.

Defining Filter Scopes

Create filter scopes by adding methods prefixed with scope to your model:

use Lightpack\Database\Lucid\Model;

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

    protected function scopeStatus($query, $value)
    {
        $query->where('status', $value);
    }

    protected function scopeType($query, $value)
    {
        $query->where('type', $value);
    }

    protected function scopeRole($query, $value)
    {
        $query->where('role', $value);
    }

    protected function scopeSearch($query, $value)
    {
        $query->where('name', 'LIKE', "%{$value}%");
    }
}

Using Filters

Apply filters using the static filters() method:

// Fetch active users
$users = User::filters(['status' => 'active'])->all();

// Combine multiple filters
$users = User::filters([
    'status' => 'active',
    'role' => 'admin',
    'search' => 'john'
])->all();

Type hint scope parameters

You can type hint $query and $value parameters for better code clarity:

class User extends Model
{
    protected function scopeTags(Query $query, array|string $value)
    {
        if (is_string($value)) {
            $value = explode(',', $value);
        }
        $query->whereIn('tag', $value);
    }
}
// Usage
$users = User::filters([
    'tags' => 'php,mysql,redis'
])->all();
// Or with array
$users = User::filters([
    'tags' => ['php', 'mysql', 'redis']
])->all();

Global Scope

Global scopes let you automatically apply common query conditions to all queries on a model—ensuring consistent, safe, and DRY data access. This is especially powerful for multi-tenant applications, or any scenario where you want to transparently filter data for all operations.

What is a Global Scope?

A global scope is a rule that is always applied to every query for a model, whether you’re fetching, updating, deleting, or counting records. This helps prevent accidental data leaks and reduces repetitive code.

How to Define a Global Scope

To add a global scope, override the inherited method globalScope() in your model. Any conditions you add to the $query will be automatically included in all queries for that model.

Example: Restricting by Tenant

class TenantModel extends Model
{
    public function globalScope(Query $query)
    {
        // Only show records for tenant_id = 1
        $query->where('tenant_id', 1);
    }
}

Now, any model inheriting from TenantModel will always include WHERE tenant_id = 1 in its queries—no matter what operation you perform.

Why is this Powerful?

Real-World Example

Suppose you have a users table with a tenant_id column. By using a global scope, you can ensure that all queries only affect users belonging to the current tenant:

class User extends TenantModel
{
    protected $table = 'users';
}

Now, all of these will only affect tenant 1:

User::query()->all();
User::query()->count();
User::query()->where('active', 1)->all();
User::query()->delete();
User::query()->update(['active' => 0]);

Best Practices


Model Hooks

Lightpack Lucid models provide a set of protected lifecycle hook methods that allow you to inject custom logic before and after key persistence operations—without global events, observers, or magic. These hooks allow to extend model behavior making your code organized and discoverable

Available Hook Methods

Hook When is it called?
beforeSave() Before save() (both insert and update)
afterSave() After save() (both insert and update)
beforeInsert() Before insert()
afterInsert() After insert()
beforeUpdate() Before update()
afterUpdate() After update()
beforeDelete() Before delete()
afterDelete() After delete()

How and Why to Use Hooks


Practical Examples for Each Hook

beforeSave()

Called before save(), regardless of whether it inserts or updates:

protected function beforeSave()
{
    // Example: Normalize data before any persistence
    $this->email = strtolower(trim($this->email));
    // Example: Set a computed field
    $this->slug = str_slug($this->name);
}

afterSave()

Called after save(), regardless of whether it inserted or updated:

protected function afterSave()
{
    // Example: Invalidate cache after any change
    Cache::forget('user_' . $this->id);
    // Example: Sync to external service
    SearchIndex::upsert('users', $this->id, $this->toArray());
}

beforeInsert()

Called before inserting a new record:

protected function beforeInsert()
{
    // Example: Hash password before storing
    if (!empty($this->password)) {
        $this->password = password_hash($this->password, PASSWORD_DEFAULT);
    }
    // Example: Set created_by
    $this->created_by = Auth::userId();
}

afterInsert()

Called after inserting a new record:

protected function afterInsert()
{
    // Example: Send welcome email
    Mailer::sendWelcome($this->email);
    // Example: Log creation
    Audit::log('Created user: ' . $this->id);
}

beforeUpdate()

Called before updating an existing record:

protected function beforeUpdate()
{
    // Example: Prevent email change
    if ($this->isDirty('email')) {
        throw new \RuntimeException('Email cannot be changed.');
    }
    // Example: Update audit fields
    $this->updated_by = Auth::userId();
}

afterUpdate()

Called after updating an existing record:

protected function afterUpdate()
{
    // Example: Invalidate related cache
    Cache::forget('user_' . $this->id);
    // Example: Notify admin
    Notification::admin('User updated: ' . $this->id);
}

beforeDelete()

Called before deleting a record:

protected function beforeDelete()
{
    // Example: Prevent deletion if related orders exist
    if ($this->orders()->count() > 0) {
        throw new \RuntimeException('Cannot delete user with orders.');
    }
    // Example: Archive data
    ArchiveService::archive($this->toArray());
}

afterDelete()

Called after deleting a record:

protected function afterDelete()
{
    // Example: Remove from search index
    SearchIndex::remove('users', $this->id);
    // Example: Log deletion
    Audit::log('Deleted user: ' . $this->id);
}

More Realistic Scenarios


Best Practices & Gotchas


Cast Into Array

To convert loaded models into array, use toArray() method.

$product = new Product(23);
$productArray = $product->toArray();
$products = Product::query()->limit(10)->all();
$productsArray = $products->toArray();

Hidden Attributes

Sometimes, you don’t want certain model attributes to show up when converting your models to arrays or serializing them (for example, when returning JSON responses from an API). The $hidden property on your model lets you easily hide sensitive or irrelevant fields from output.

Why Hide Attributes?

How to Use

Just define the $hidden property as an array of attribute names in your model:

class User extends Model
{
    protected $hidden = [
        'password',
        'remember_token',
        'internal_notes',
    ];
}

Now, when you call toArray() or serialize the model (e.g., for JSON), these fields will be automatically excluded:

$user = new User(23);
$userArray = $user->toArray();
// 'password', 'remember_token', and 'internal_notes' will NOT appear in $userArray

This also applies to collections:

$users = User::query()->all();
$usersArray = $users->toArray(); // All hidden fields are excluded for every user

Best Practices