Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

Models

Eloquent: relationships

Tables relate to each other: a post has comments, a user has roles. Define each relation once, as a method of your model, then query it, load it with its parent, and write through it.

Introduction#

A relation is a method of your model that returns the relation, such as this.hasMany(Comment). Calling it gives a query of the related models, with every query builder method, already limited to this model's:

const comments = await post.comments().where('votes', '>', 5).get()

To have a relation's models with their parent, load it: Post.with('comments') (see Eager loading).

Defining relationships#

One to one / has one#

A user has one phone. hasOne() takes the related model, and finds it by a user_id column on its table:

app/Models/User.ts
import { Model } from '@orieljs/framework'import { Phone } from './Phone'export class User extends Model {  /** Get the phone associated with the user. */  phone() {    return this.hasOne(Phone)  }}

The foreign key is the model's name in snake case with _id. Give another as the second argument, and the column it matches on this model, id unless you say, as the third: this.hasOne(Phone, 'foreign_key', 'local_key').

Defining the inverse of the relationship#

The phone belongs to its user. belongsTo() reads the phone's user_id, named after the related model:

app/Models/Phone.ts
import { Model } from '@orieljs/framework'import { User } from './User'export class Phone extends Model {  /** Get the user that owns the phone. */  user() {    return this.belongsTo(User)  }}

this.belongsTo(User, 'foreign_key', 'owner_key') names the columns yourself.

One to many / has many#

A post has many comments. hasMany() finds them by their post_id:

app/Models/Post.ts
import { Model } from '@orieljs/framework'import { Comment } from './Comment'export class Post extends Model {  /** Get the comments for the blog post. */  comments() {    return this.hasMany(Comment)  }}
const post = await Post.findOrFail(1)const comments = await post.comments().get()const comment = await post.comments().where('body', 'like', '%Oriel%').first()

One to many (inverse) / belongs to#

app/Models/Comment.ts
import { Model } from '@orieljs/framework'import { Post } from './Post'export class Comment extends Model {  /** Get the post that owns the comment. */  post() {    return this.belongsTo(Post)  }}
const comment = await Comment.findOrFail(1)const post = await comment.post().first()

Automatically hydrating parent models on children#

Loading a post's comments, then each comment's post, would query the post again for every comment. chaperone() on the relation sets each loaded comment's post to the post it was loaded with, with no query:

app/Models/Post.ts
import { Model } from '@orieljs/framework'import { Comment } from './Comment'export class Post extends Model {  comments() {    return this.hasMany(Comment).chaperone()  }}

Has many through#

A has-many-through relation reaches models through another one. A project has environments, and each environment has deployments, so a project's deployments are reached through its environments:

app/Models/Project.ts
import { Model } from '@orieljs/framework'import { Deployment } from './Deployment'import { Environment } from './Environment'export class Project extends Model {  /** Get all of the deployments for the project. */  deployments() {    return this.hasManyThrough(Deployment, Environment)  }}

The keys are project_id on environments and environment_id on deployments; the next arguments name others.

Many to many relationships#

A user has many roles, and a role belongs to many users. The two tables meet in a third, the pivot table, named after both models in alphabetical order: role_user, with user_id and role_id.

app/Models/User.ts
import { Model } from '@orieljs/framework'import { Role } from './Role'export class User extends Model {  /** The roles that belong to the user. */  roles() {    return this.belongsToMany(Role)  }}

The role's side is the same: this.belongsToMany(User). The next arguments name the pivot table and its keys: this.belongsToMany(Role, 'role_user', 'user_id', 'role_id').

const roles = await user.roles().orderBy('name').get()

Retrieving intermediate table columns#

Each model a many-to-many query returns has a pivot, a model with its pivot row's keys. withPivot() adds other columns of the pivot table, and withTimestamps() its created_at and updated_at, which attach() then sets:

app/Models/User.ts
import { Model } from '@orieljs/framework'import { Role } from './Role'export class User extends Model {  roles() {    return this.belongsToMany(Role).withPivot('active', 'created_by').withTimestamps()  }}
for (const role of await user.roles().get()) {  console.log(role.pivot?.getAttribute('created_at'))}

Filtering queries via intermediate table columns#

wherePivot() and wherePivotIn() filter by the pivot table's columns:

const active = await user.roles().wherePivot('active', 1).get()const some = await user.roles().wherePivotIn('priority', [1, 2]).get()

Polymorphic relationships#

A polymorphic relation lets a model belong to more than one kind of model. An image can belong to a user or to a post: its imageable_id holds the owner's key, and imageable_type the owner's kind.

One to one (polymorphic)#

app/Models/Image.ts
import { Model } from '@orieljs/framework'export class Image extends Model {  /** Get the parent imageable model (user or post). */  imageable() {    return this.morphTo('imageable')  }}
app/Models/Post.ts
import { Model } from '@orieljs/framework'import { Image } from './Image'export class Post extends Model {  /** Get the post's image. */  image() {    return this.morphOne(Image, 'imageable')  }}

The table needs the two columns, which table.morphs('imageable') adds in a migration. morphTo() takes the relation's name, from which the columns are named. The name is required: Laravel reads it from the method that calls morphTo(), which JavaScript can't see.

const post = await Post.findOrFail(1)const image = await post.image().first()const owner = await (await Image.findOrFail(1)).imageable().first()

One to many (polymorphic)#

morphMany() gives a model many of them: this.morphMany(Comment, 'commentable').

Custom polymorphic types#

The _type column holds the owner's class name, Post, unless you map names to models. A map keeps your data from depending on your class names:

app/Providers/AppServiceProvider.ts
import { Relation, ServiceProvider } from '@orieljs/framework'import { Post } from '../Models/Post'import { User } from '../Models/User'export class AppServiceProvider extends ServiceProvider {  override boot(): void {    Relation.morphMap({ post: Post, user: User })  }}

Querying relations#

Relationship methods vs. reading a relation#

In Laravel, $post->comments reads a relation as a property. In Oriel, post.comments is the relation method itself, as it is on any class, so reading through it, post.author.name, gives you a property of the function, with no error. Read a relation one of two ways:

  • Query it: call the method, and run the query: await post.author().first(), or await post.comments().get().
  • Load it first, then read it: Post.with('author') or post.load('author'), then post.getRelation('author'), typed from the method: a User or null for a belongs-to, a collection for a has-many.
const post = await Post.with('author').findOrFail(1)const author = post.getRelation('author') // User | nullconst comments = await post.comments().get()

getRelation() on a relation that isn't loaded throws a RelationNotLoadedError, which says how to load it.

Querying relationship existence#

has() limits a query to the models that have related models, at least one or as many as you say. A dotted name reaches through relations:

const posts = await Post.has('comments').get()const popular = await Post.has('comments', '>=', 3).get()const writers = await User.has('posts.comments').get()

whereHas() adds conditions on the related models:

const posts = await Post.whereHas('comments', (query) => {  query.where('body', 'like', 'code%')}).get()

orHas() and orWhereHas() join with or.

Querying relationship absence#

doesntHave() and whereDoesntHave() are their opposites:

const quiet = await Post.doesntHave('comments').get()const clean = await Post.whereDoesntHave('comments', (query) => {  query.where('body', 'like', 'spam%')}).get()

has() and its kin don't work on a morphTo() relation, whose related table isn't known in advance.

withCount() adds a <relation>_count attribute with the number of related models, without loading them:

const posts = await Post.withCount('comments').get()for (const post of posts) {  console.log(post.getAttribute('comments_count'))}

as names the attribute, and an object gives each count its conditions:

const posts = await Post.withCount('comments as comment_total')  .withCount({ 'comments as popular_count': (query) => query.where('votes', '>', 5) })  .get()

Deferred count loading#

loadCount() counts on a model you already have:

const post = await Post.findOrFail(1)await post.loadCount('comments')

Other aggregate functions#

withSum(), withAvg(), withMin() and withMax() add <relation>_<function>_<column>, and withExists() adds <relation>_exists:

const posts = await Post.withSum('comments', 'votes').withExists('comments').get()for (const post of posts) {  console.log(post.getAttribute('comments_sum_votes'), post.getAttribute('comments_exists'))}

loadSum(), loadAvg(), loadMin(), loadMax() and loadExists() add them to a model you have.

Eager loading#

Reading the author of each of 25 posts, one at a time, runs 26 queries: one for the posts, then one per author. with() loads the authors of all of them in one more query:

const posts = await Post.with('author').get()for (const post of posts) {  console.log(post.getRelation('author')?.name)}

Eager loading multiple relationships#

const posts = await Post.with('author', 'comments').get()

Nested eager loading#

A dotted name loads a relation of a relation:

const posts = await Post.with('comments.post').get()

Eager loading specific columns#

const posts = await Post.with('author:id,name').get()

List the key the relation matches on, here id, or the models can't be matched to their parents.

Eager loading by default#

with on the model loads its relations for every query, and withCount counts them:

export class Post extends Model {  protected override with = ['author']}

without() leaves a default relation out of one query.

Constraining eager loads#

Give with() an object to add conditions to a relation's query:

const posts = await Post.with({ comments: (query) => query.where('body', 'like', '%code%') }).get()

Lazy eager loading#

load() loads relations on a model you already have, and loadMissing() loads only those that aren't loaded yet:

const post = await Post.findOrFail(1)if (post.getAttribute('author_id') !== null) {  await post.load('author', 'comments')}await post.loadMissing('comments')

The save method#

save() on a has-many relation sets the model's foreign key and saves it, and saveMany() does it for several:

const comment = Comment.make({ body: 'A new comment.' })const post = await Post.findOrFail(1)await post.comments().save(comment)await post.comments().saveMany([Comment.make({ body: 'Another new comment.' }), Comment.make({ body: 'Another.' })])

The create method#

create() makes the related model with the foreign key set and saves it, and createMany() makes several:

const post = await Post.findOrFail(1)const comment = await post.comments().create({ body: 'A new comment.' })await post.comments().createMany([{ body: 'A new comment.' }, { body: 'Another new comment.' }])

Belongs to relationships#

associate() sets a model's foreign key to its new parent, and dissociate() clears it. Save the model after:

const user = await User.findOrFail(10)const phone = await Phone.findOrFail(1)phone.user().associate(user)await phone.save()phone.user().dissociate()await phone.save()

Many to many relationships#

Attaching / detaching#

attach() adds a row to the pivot table, with any extra pivot columns, and detach() removes one, several, or every row of this model:

const user = await User.findOrFail(1)await user.roles().attach(1, { expires: new Date('2026-12-31') })await user.roles().detach(1)await user.roles().detach()

Syncing associations#

sync() leaves exactly the ids you give attached, adding and removing rows, and returns what it changed. syncWithoutDetaching() only adds, and toggle() attaches the ids that aren't attached and detaches those that are:

const changes = await user.roles().sync([1, 2, 3]) // { attached: [...], detached: [...], updated: [...] }await user.roles().syncWithoutDetaching([1, 2, 3])await user.roles().toggle([1, 2, 3])

Updating a record on the intermediate table#

await user.roles().updateExistingPivot(1, { active: false })

Serializing relations#

A model's toArray() and its JSON include the relations it has loaded, under their names in snake case (blogPosts as blog_posts), as their models' own arrays. A relation that isn't loaded isn't there.

Privacy policy ·

esc