ORM Relationships

Introduction

Order UI and Entity Diagram

ORMs turn the abstract relationships of your database into natural, intuitive code. By mapping database associations to model methods, you unlock the full power of relational data—without the pain of raw SQL joins.

To set the context let's understand the diagram above. It shows a simplified version of an order screen on the left and associated table entities on the right. The order UI shows information about the associated order, customer, order items, and payment details.

Data Modelling

Although the order UI shows all the information together on the screen, when modelling the database schema, you would consider normalized forms with:

Note: to uniquely identify the order and its related items, we need a table named order_item which stores references to the order_id and product_id. Such a table is often called a pivot or junction or bridge table.

Entity Associations

When relating data models (entities) together, we may think in terms of associations or relationships. For example:

In Relation Databases, we introduce 4 main types of associations:

Association Types between Entities

Association Types

One to One (1:1)

A One to One relationship means that each record in Table A is linked to one and only one record in Table B, and vice versa.

Think of it as a passport and a person: each person has one unique passport, and each passport belongs to one person.

Order Example:

In Schema:

One to Many (1:N)

A One to Many relationship means that a single record in Table A can be related to many records in Table B, but each record in Table B relates back to only one record in Table A.

Imagine a customer placing multiple orders: one customer, many orders.

Order Example:

Schema:

Many to One (N:1)

A Many to One relationship is simply the inverse of One to Many. Many records in Table A relate to a single record in Table B.

Think of students and schools: Many students can attend the same school, but each student is enrolled in only one school.

Order Example:

Schema:

Many to Many (N:M)

A Many to Many relationship means that multiple records in Table A can relate to multiple records in Table B. This is typically implemented using a junction (pivot) table.

Think of products and orders: an order can have many products, and a product can appear in many orders.

Order Example:

Schema:

Summary:

  • One to One: Each order has one payment.
  • One to Many: One customer, many orders.
  • Many to One: Many order items, one order.
  • Many to Many: Many products in many orders, connected by order items.

Relationship Methods

Now that you’ve seen how relationships are structured at the database level, let’s translate these concepts into the world of ORMs. While the database focuses on tables, foreign keys, and junction tables, an ORM lets you work with your data as rich, interconnected objects—making your code more expressive, maintainable, and closer to how you think about your domain.

In an ORM, each type of database relationship is represented by a specific method or association on your model classes. Instead of writing SQL joins, you define these relationships once, and then access related data as if you were simply navigating object properties.

Has One

ORM One to One Association

Use the relationship method hasOne() to define one to one relationhsip between order and payment entity.

class Order extends Model
{
    public function payment()
    {
        return $this->hasOne(Payment::class, 'order_id');
    }
}

Now to get the associated payment for an order, simply use the name of the payment() method on Order instance.

/**
 * Find order with id: 23
 */
$order = new Order(23);

/**
 * Get the associated payment
 */
$order->payment;

Behind the scenes, the ORM intercepts the call to $order->payment and resolves the associated Payment instance.

Belongs To

Use the relationship method belongsTo() to define the inverse of the hasOne() relationship.

class Payment extends Model
{
    public function order()
    {
        return $this->belongsTo(Order::class, 'order_id');
    }
}

Now this makes it possible to fetch the Order instance that the Payment belongs to.

/**
 * Find the payment with id: 101
 */
$payment = new Payment(101);

/**
* Get the associated order
*/
$payment->order;

Has Many

ORM One to Many Association

Use the relationship method hasMany() to define a one to many relationship between the customer and order entities.

class Customer extends Model
{
    public function orders()
    {
        return $this->hasMany(Order::class, 'customer_id');
    }
}

Now, to get all orders placed by a customer, simply use the name of the orders() method on a Customer instance.

/**
 * Find the customer with id: 7
 */
$customer = new Customer(7);

/**
 * Get all orders for this customer
 */
$orders = $customer->orders;

Behind the scenes, the ORM intercepts the call to $customer->orders and returns a collection of Order instances related to that customer.

Inverse of Has Many

You should not be surprised to know that belongsTo() also represents the inverse of hasMany().

class Order extends Model
{
    public function customer()
    {
        return $this->belongsTo(Customer::class, 'customer_id');
    }
}

Now, you can fetch the Customer instance for a given Order:

/**
 * Find the order with id: 42
 */
$order = new Order(42);

/**
 * Get the customer who placed this order
 */
$customer = $order->customer;

Many to Many

ORM Many to Many Association

Use the relationship method pivot() to define a many to many relationship between the order and product entities, using a pivot table (like order_item).

class Order extends Model
{
    public function products()
    {
        return $this->pivot(Product::class, 'order_item', 'order_id', 'product_id');
    }
}

Now, to get all products in a given order, simply use the name of the products() method on an Order instance.

/**
 * Find the order with id: 12
 */
$order = new Order(12);

/**
 * Get all products in this order
 */
$products = $order->products;

Behind the scenes, the ORM joins the orders, order_item, and products tables to return all related Product instances for the order.

Inverse of Many to Many

Essentailly many to many relationship works both ways, so use the same relationship method on the Product model to access all orders that include a given product.

class Product extends Model
{
    public function orders()
    {
        return $this->pivot(Order::class, 'order_item', 'product_id', 'order_id');
    }
}

Now, you can fetch all orders that include a specific product:

/**
 * Find the product with id: 99
 */
$product = new Product(99);

/**
 * Get all orders that include this product
 */
$orders = $product->orders;

Attach Pivot Records

To insert a pivot record, use attach() method.

Let's take User and Role models for example.

class User extends Model
{
    /**
     * A user has many roles assigned.
     */ 
    public function roles()
    {
        return $this->pivot(Role::class, 'user_role', 'user_id', 'role_id');
    }
}

Now to assign new roles to the user:

$user = new User(23);

// Attach a role to the user
$user->roles()->attach(1);

// Attach multiple roles together
$user->roles()->attach([1, 2]);

// Pass additional attributes in pivot table
$user->roles()->attach([1, 2], [
    'assigned_by' => $adminId,
    'assigned_at' => now(),
]);

Detach Pivot Records

To delete a pivot record, use detach() method. It removes records in the pivot table, supporting extra columns as additional where filters.

$user = new User(23);

// Remove role 1 for the user
$user->roles()->detach(1);

// Remove multiple roles together
$user->roles()->detach([1, 2]);

// Remove roles 2 and 3 only if assigned_by matches
$user->roles()->detach([2, 3], ['assigned_by' => $adminId]);

Sync Pivot Records

To update pivot records, use sync() method. What is syncing pivot records?

$user = new User(23);

// Assign roles 1, 2, 3 to user, removing any others
$user->roles()->sync([1, 2, 3]);

// Sync with extra data (e.g., assigned_at timestamp)
$user->roles()->sync([1, 2], ['assigned_at' => now()]);

Note that all sync operations are wrapped in a transaction:

Through Relationships

Has One

The hasOneThrough() relationship method lets you access a single, distant related record through an intermediate model. This is ideal for cases where you want to “reach through” one model to get a single related record from another.

Consider this example:

This pattern allows you to fetch the doctor for a patient, even though the doctor is not directly linked to the patient, but is associated through the patient’s appointment.

Example: Patient, Appointment, Doctor

class Patient extends Model
{
    // Each patient has one doctor through their appointment
    public function doctor()
    {
        return $this->hasOneThrough(
            Doctor::class,      // Final model
            Appointment::class, // Through model
            'patient_id',       // Foreign key on through table
            'doctor_id'         // Foreign key on final table
        );
    }
}

Now you can easily fetch the doctor for a patient:

$patient = new Patient(1);
$doctor = $patient->doctor;

Behind the scenes, Lightpack ORM joins the patients, appointments, and doctors tables to fetch the doctor for the patient’s appointment—no manual SQL or nested queries required.


Has Many

The hasManyThrough() relationship method lets you access related records that are connected by an intermediate model. This is perfect for scenarios where you want to “reach through” one model to get to another.

Consider this example:

This means you can fetch all reviews for an author, even though reviews are not directly linked to the author, but come through the author’s books.

class Author extends Model
{
    // One author has many reviews through books
    public function reviews()
    {
        return $this->hasManyThrough(
            Review::class, // Final model
            Book::class,   // Through model
            'author_id',   // Foreign key on through table
            'book_id'      // Foreign key on final table
        );
    }
}

Now you can easily fetch associated reviews,

$author = new Author(1);
$reviews = $author->reviews;

Behind the scenes, Lightpack ORM joins the authors, books, and reviews tables to fetch all reviews for books written by that author—no manual SQL or nested loops required.


Polymorphic Relationships

Introduction

Polymorphic relationships are a powerful feature that let a single model relate to more than one type of parent model—using a unified, elegant approach. In Lightpack ORM, this is implemented with confidence and clarity, so you can tackle real-world use cases like comments, media attachments, or user avatars without convoluted table structures.

Polymorphic Relationship Diagram

Polymorphic Table Schema Example

Your polymorphic child table (e.g., comments) must have columns named exactly morph_id and morph_type:

CREATE TABLE comments (
    id INT PRIMARY KEY AUTO_INCREMENT,
    morph_id INT NOT NULL,
    morph_type VARCHAR(64) NOT NULL,
    body TEXT,
    created_at DATETIME,
    updated_at DATETIME
);

This enforced naming approach makes your migrations and queries consistent, readable, and future-proof.

Column Naming Convention: Lightpack requires you to name your polymorphic columns as morph_id and morph_type—no exceptions. This is a deliberate design choice. To avoid awkward column names like commentable_id, articleable_id, or imageable_id, Lightpack keeps it simple and predictable. Your schema is always easy to interpret, and your code stays clean.


When (Not) to Use Polymorphic Relations

Polymorphic relations are a pragmatic solution for flexible data models, but they come with tradeoffs:

Bottom line: If you require absolute referential integrity, avoid polymorphic relations—split your tables or redesign your schema. But if you need flexibility and can enforce integrity at the application level, Lightpack’s polymorphic support is robust, expressive, and easy to use.

Referential Integrity Warning: Polymorphic relationships are not enforced by database-level foreign keys. The integrity is maintained by your application and ORM alone. If you need strict referential integrity, avoid polymorphic patterns—split your tables or redesign your schema. Use polymorphic relations only when flexibility outweighs the need for DB-enforced constraints.


Polymorphic relationships in Lightpack are designed to make your codebase more maintainable, not more confusing. Use them wisely, and you’ll unlock elegant solutions to complex data modeling challenges. Lets explore the polymorphic relationship methods available:

morphOne()
morphMany()
morphTo()

Morph One

For a one-to-one polymorphic relationship, such as a User having a single Avatar, use the morphOne() method to fetch related avatar model:

class User extends Model
{
    public function avatar()
    {
        return $this->morphOne(Avatar::class);
    }
}

Usage:

$user = new User(42);
$avatar = $user->avatar;

Morph Many

For a polymorphic one-to-many relationship, such as each Post, Photo, or Video having many Comments, use the morphMany() method to fetch related comments model collection.

class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class);
    }
}

class Photo extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class);
    }
}

class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class);
    }
}

Usage:

$video = new Video(7);
$comments = $video->comments; // All comments for this video

Morph Inverse

Use the method morphTo() to define the polymorphic inverse relation to fetch related parent model.

Suppose you want to fetch parent Post, Photo, or Video model for the Comment model:

class Comment extends Model
{
    public function parent()
    {
        return $this->morphTo([
            Post::class,
            Photo::class,
            Video::class,
        ]);
    }
}

Now, given a comment, you can access its parent—no matter the type:

$comment = new Comment(101);
$parent = $comment->parent; // Could be a Post, Photo, or Video instance

Morph To Many

Polymorphic Many-to-Many Relationship

A polymorphic many-to-many relationship lets you share a single pivot table across multiple parent models. For example, both Posts and Videos can have Tags, but instead of creating separate post_tag and video_tag tables, you use one tag_morphs table that works for both.

Why use this?

When multiple models need the same many-to-many relationship (like tagging, categorization, or permissions), a polymorphic pivot table keeps your schema clean and your code DRY.

Schema Requirements:

The pivot table must have three columns:

Example tag_morphs table:

CREATE TABLE tag_morphs (
    tag_id BIGINT UNSIGNED,
    morph_id BIGINT UNSIGNED,
    morph_type VARCHAR(255),
    PRIMARY KEY (tag_id, morph_id, morph_type)
);

Usage:

Use morphToMany() to define the relationship from the parent models (Post, Video) to the related model (Tag).

class Post extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'tag_morphs', 'tag_id');
    }
}

class Video extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'tag_morphs', 'tag_id');
    }
}

Usage:

$post = new Post(12);
$tags = $post->tags; // All tags for this post

Pivot Operations:

// Attach tags
$post->tags()->attach([1, 2, 3]);

// Detach tags
$post->tags()->detach([2]);

// Sync tags (removes old, adds new)
$post->tags()->sync([1, 3, 4]);

Morphed By Many

Use morphedByMany() to define the inverse polymorphic many-to-many relationship. For example, a Tag can belong to many Posts and many Videos.

class Tag extends Model
{
    public function posts()
    {
        return $this->morphedByMany(Post::class, 'tag_morphs', 'tag_id');
    }

    public function videos()
    {
        return $this->morphedByMany(Video::class, 'tag_morphs', 'tag_id');
    }
}

Usage:

$tag = new Tag(5);
$posts = $tag->posts;   // All posts with this tag
$videos = $tag->videos; // All videos with this tag

Counting Relations

withCount() works across all collection-style relationships—not just hasMany. This includes many-to-many (pivot), polymorphic many-to-many (morphToMany, morphedByMany), and through relationships.

Many-to-many (pivot):

// How many roles does each user have?
$users = User::query()->withCount('roles')->all();

foreach ($users as $user) {
    echo $user->roles_count;
}

Polymorphic many-to-many (morphToMany):

// How many tags does each post have?
$posts = Post::query()->withCount('tags')->all();

foreach ($posts as $post) {
    echo $post->tags_count;
}

Inverse polymorphic many-to-many (morphedByMany):

// How many posts and videos does each tag have?
$tags = Tag::query()
    ->withCount('posts')
    ->withCount('videos')
    ->all();

foreach ($tags as $tag) {
    echo $tag->posts_count;
    echo $tag->videos_count;
}

You can also combine withCount() with orderBy() on these relations:

// Users ordered by number of roles
$users = User::query()
    ->withCount('roles')
    ->orderBy('roles_count', 'desc')
    ->all();

For a complete reference on withCount(), withSum(), and all other aggregate facilities including defaults, callbacks, and deferred loading, see the Eager Loading documentation.


Querying Relationships

Understanding how to access and work with relationships is fundamental to getting the most out of your ORM. Lightpack ORM makes it intuitive to fetch related data, whether you want a single associated record or a whole collection of related models. This section will guide you through the mechanics, best practices, and the semantics of querying relationships. So let's reconsider the relation where an organization has many departments.

class Organization extends Model
{
    public function departments()
    {
        return $this->hasMany(Department::class, 'organization_id');
    }
}

Accessing Relationships: Property vs. Method

You can access a relationship in two ways:

  1. As a dynamic property:

     $org = new Organization(1);
     $departments = $org->departments; // Property access

    When you access a relationship as a property, the ORM automatically runs the underlying query and returns the related data. This is the most common and convenient way to fetch associated models.

  2. As a method call:

     $query = $org->departments(); // Method access

    When you call the relationship as a method, you get the underlying query builder. This allows you to further customize the query before executing it:

     // Get all active departments
     $activeDepartments = $org->departments()->where('status', 'active')->all();
    
     // Get the first department
     $firstDept = $org->departments()->one();
    
     // Find a specific department by ID
     $dept = $org->departments()->find(5);

What Happens Behind the Scenes?

Finding Specific Related Records

When you need to find a specific related record by its primary key, use the find() method on the relationship. This ensures that the record not only exists but also belongs to the parent model.

$org = new Organization(1);

// Find department with ID 5 that belongs to this organization
$dept = $org->departments()->find(5);

Exception Handling:

By default, find() throws a RecordNotFoundException if the record is not found. This is consistent with the behavior of Model::find().

try {
    $dept = $org->departments()->find(999);
} catch (RecordNotFoundException $e) {
    // Handle not found
}

Returning Null Instead:

If you prefer to receive null instead of an exception when a record is not found, pass false as the second parameter:

$dept = $org->departments()->find(5, false);

if (!$dept) {
    // Department not found or doesn't belong to this organization
}

Security Benefit:

Using find() on relationships ensures that the record belongs to the parent model. For example:

// This will only find department 5 if it belongs to organization 1
$dept = $org->departments()->find(5);

// If department 5 belongs to a different organization, it returns null (or throws exception)

This prevents unauthorized access to records that don't belong to the parent model, making your application more secure.

Chaining with Other Constraints:

You can combine find() with other query constraints:

// Find an active department with ID 5
$dept = $org->departments()
    ->where('status', 'active')
    ->find(5);

Collections: Working with Multiple Related Models

When a relationship returns multiple models (such as with hasMany, belongsToMany, or morphMany), the result is a Collection object. This collection behaves much like an array, but is enhanced with a rich set of methods for filtering, mapping, reducing, and more. For example:

$departments = $org->departments; // Collection of Department models

// Get the names of all departments
$names = $departments->column('name');

// Filter only active departments
$active = $departments->filter(function($dept) {
    return $dept->status === 'active';
});

Collections make it easy to work with groups of related models in a fluent, expressive way. You’ll find a full guide to collections in a dedicated section of this documentation.

Best Practices and Semantics


By understanding the difference between property and method access, and how collections work, you’ll write more expressive, efficient, and maintainable code with Lightpack ORM. For a deep dive into collections and their powerful capabilities, see the Collections documentation.


Semantic Relationship Methods

Semantic relationship methods empower you to define model relationships that are not only technically correct, but also meaningful and intention-revealing. Instead of limiting your models to generic accessors like departments(), you can define expressive methods such as activeDepartments(), hrDepartments(), or recentlyCreatedDepartments(). This approach makes your codebase more readable, maintainable, and aligned with real business logic.

Why Semantic Methods?

Example: Organization and Departments

Suppose you have an Organization model and a related Department model. Each organization can have many departments, but you want to easily fetch only the active ones, or only those in the HR domain.

Standard Relationship

class Organization extends Model
{
    // All departments for this organization
    public function departments()
    {
        return $this->hasMany(Department::class, 'organization_id');
    }
}

Semantic (Filtered) Relationships

class Organization extends Model
{
    // Only active departments
    public function activeDepartments()
    {
        return $this->hasMany(Department::class, 'organization_id')->where('status', 'active');
    }

    // Only HR departments
    public function hrDepartments()
    {
        return $this->hasMany(Department::class, 'organization_id')->where('type', 'hr');
    }

    // Departments created in the last 30 days
    public function recentlyCreatedDepartments()
    {
        return $this->hasMany(Department::class, 'organization_id')
                    ->where('created_at', '>=', now()->subDays(30));
    }
}

Usage

$org = new Organization(1);

// Get all departments
$departments = $org->departments;

// Get only active departments
$active = $org->activeDepartments;

// Get only HR departments
$hr = $org->hrDepartments;

// Get recently created departments
$recent = $org->recentlyCreatedDepartments;

Best Practices

When to Use Semantic Relationships


Semantic relationship methods are a powerful way to make your models expressive, maintainable, and aligned with your domain. By naming relationships for what they mean, not just what they are, you create a codebase that’s easier to read, reason about, and extend.

How does this work behind the scenes?

Each relationship method returns a query builder. This means you can chain and apply any filtering, sorting, or limiting logic directly within your relationship method. When you access a property like $organization->activeDepartments, the ORM executes the query as defined in your method—including all your custom conditions—and returns the result. This is why you can define as many semantic, filtered relationships as your application needs.