Advanced Prisma Queries: Include, Select & Relations vs Laravel Eloquent
Step 9 of 31 — Next.js Tutorial Series
What You Will Learn
This is an advanced topic step. We are not building new features here — instead, we take a close look at how Prisma fetches data under the hood and compare every concept with its Laravel Eloquent equivalent.
If you have used Laravel before, this step will help you map your existing mental model to Prisma. If you have not, it still serves as a thorough reference for Prisma's query system.
Goal: Understand include, select, nested relations, and Prisma's explicit data-fetching philosophy.
Table of Contents
- The Key Design Difference
- Without
include— Foreign Keys Only - With
include— Eager Loading - Why Prisma Requires
include - Laravel Equivalent
- Using
selectfor Partial Fields - Nested Relations
- Filtering with
where relationLoadStrategy— join vs query- Prisma vs Laravel — Quick Reference
- Best Practices
- Summary & Key Takeaways
The Key Design Difference
In Laravel, you can access a relation at any time. If you write $post->author->name, Eloquent will silently run an extra SQL query to fetch the author. This is called lazy loading — convenient, but it can cause the infamous N+1 problem when used inside a loop.
Prisma takes a different approach: nothing is loaded unless you explicitly ask for it. If you do not write include: { author: true }, the author data simply will not be there. There is no lazy loading, no hidden queries, and no surprises.
| Philosophy | Laravel Eloquent | Prisma |
|---|---|---|
| Default behavior | Lazy loading (query on access) | No loading (foreign key only) |
| Eager loading | with('author') | include: { author: true } |
| Risk of N+1 | Yes, if you forget with() | No, impossible by design |
Without include — Foreign Keys Only
When you query posts without requesting the relation:
const posts = await prisma.post.findMany({
orderBy: { createdAt: 'desc' },
})
Prisma generates:
SELECT *
FROM "Post"
ORDER BY "createdAt" DESC;
The result contains the foreign key but not the related object:
[
{
"id": 1,
"title": "Hello",
"content": "My first post",
"authorId": 1,
"published": true
}
]
You get authorId (the integer), but there is no author property. If you try to access posts[0].author, TypeScript will give you a compile-time error — the property does not exist on the type.
Laravel equivalent
$posts = Post::all();
// $posts[0]->authorId exists
// $posts[0]->author ← triggers a lazy-loaded query (hidden SQL!)
In Laravel, accessing $post->author silently runs SELECT * FROM users WHERE id = ?. In Prisma, it simply does not exist.
With include — Eager Loading
When you add include, Prisma fetches the related data in the same query:
const posts = await prisma.post.findMany({
include: { author: true },
})
Prisma internally generates a JOIN:
SELECT p.*, u.*
FROM "Post" p
LEFT JOIN "User" u ON p."authorId" = u."id";
Now each post has a full author object attached:
[
{
"id": 1,
"title": "Hello",
"content": "My first post",
"authorId": 1,
"author": {
"id": 1,
"name": "John",
"email": "[email protected]"
}
}
]
Laravel equivalent
$posts = Post::with('author')->get();
include in Prisma = with() in Laravel. Both perform eager loading and avoid N+1 queries.
Why Prisma Requires include
Your schema defines how tables are related, not what to fetch:
model Post {
authorId Int?
author User? @relation(fields: [authorId], references: [id])
}
This tells Prisma: "Posts can have an author, and this is how the tables are linked."
But Prisma still needs a separate instruction: "Do you actually want the author data in this particular query?"
That instruction is include: { author: true }.
Think of it as two separate concerns:
schema.prisma → Defines relationships (structure)
include / select → Controls what is fetched (data)
In Laravel, the model defines both the relationship and allows lazy access. In Prisma, the schema defines the relationship, but you must always opt in to loading the data.
Laravel Equivalent
Let's put the two side by side for a complete comparison.
Lazy loading (Laravel only — not available in Prisma)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
// ⚠️ Each iteration runs: SELECT * FROM users WHERE id = ?
// 10 posts = 10 extra queries (N+1 problem)
}
Prisma does not support lazy loading. This is intentional — it prevents N+1 problems entirely.
Eager loading (both)
// Laravel
$posts = Post::with('author')->get();
// 2 queries total: one for posts, one for authors
// Prisma
const posts = await prisma.post.findMany({
include: { author: true },
})
// 1 query (JOIN) or 2 queries depending on strategy
Eager loading with specific fields (both)
// Laravel
$posts = Post::with('author:id,name,email')->get();
// Prisma
const posts = await prisma.post.findMany({
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
Using select for Partial Fields
Sometimes you do not need the entire related object. use select inside include to pick specific fields:
Laravel comparison: select is like an API Resource
If you come from Laravel, think of Prisma's select as combining the job of Eloquent's select() and API Resources (JsonResource). In Laravel, you typically control what fields are exposed in your API response by creating a Resource class:
// Laravel — PostResource controls the response shape
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'created_at' => $this->created_at,
'author' => [
'id' => $this->author->id,
'name' => $this->author->name,
],
];
}
}
// Usage in controller
return PostResource::collection(Post::with('author')->get());
The problem: Laravel still fetches all columns from the database — the Resource only hides them from the JSON output. The unnecessary data still travels from the database to your application.
With Prisma's select, you solve both problems at once — the database only returns the fields you ask for, and the response only contains those fields:
// Prisma — select controls BOTH the query AND the response shape
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
id: true,
name: true,
},
},
},
})
No extra Resource class needed. The select is your query optimizer and your response formatter in one.
Using select inside include
You can also use select inside include to pick specific fields from a relation while still fetching all fields from the parent:
const posts = await prisma.post.findMany({
include: {
author: {
select: {
name: true,
email: true,
},
},
},
})
Result:
{
"id": 1,
"title": "Hello",
"content": "My first post",
"authorId": 1,
"author": {
"name": "John",
"email": "[email protected]"
}
}
Top-level select
You can also use select at the top level to control which Post fields are returned:
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
id: true,
name: true,
},
},
},
})
Result — only the fields you asked for:
{
"id": 1,
"title": "Hello",
"createdAt": "2026-03-12T00:00:00.000Z",
"author": {
"id": 1,
"name": "John"
}
}
Important rule: include and top-level select cannot be used together
You must choose one at the top level:
// ✅ include — fetches all Post fields + the relation
include: { author: true }
// ✅ select — fetches only the specified Post fields + the relation
select: { title: true, author: { select: { name: true } } }
// ❌ ERROR — cannot combine top-level include and select
select: { title: true }, include: { author: true }
In Laravel, there is no such restriction — select() and with() work independently.
Nested Relations
You can load relations of relations. For example, fetch each post with its author and the author's other posts:
const posts = await prisma.post.findMany({
include: {
author: {
include: {
posts: true,
},
},
},
})
The resulting shape:
Post
└── author (User)
└── posts (Post[]) — all posts by this author
Laravel equivalent
$posts = Post::with('author.posts')->get();
Laravel uses dot notation ('author.posts'). Prisma uses nested objects. Same result, different syntax.
Multiple relations
If your Post model had both author and comments, you can load them all:
const posts = await prisma.post.findMany({
include: {
author: true,
comments: true,
},
})
// Laravel equivalent
$posts = Post::with(['author', 'comments'])->get();
Filtering with where
Prisma's where is equivalent to Laravel's query builder methods.
Basic filter
// Prisma
const published = await prisma.post.findMany({
where: { published: true },
})
// Laravel
$published = Post::where('published', true)->get();
Multiple conditions (AND)
// Prisma — multiple fields in the same where object = AND
const posts = await prisma.post.findMany({
where: {
published: true,
authorId: 1,
},
})
// Laravel
$posts = Post::where('published', true)
->where('author_id', 1)
->get();
OR conditions
// Prisma
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: 'prisma' } },
{ content: { contains: 'prisma' } },
],
},
})
// Laravel
$posts = Post::where(function ($query) {
$query->where('title', 'like', '%prisma%')
->orWhere('content', 'like', '%prisma%');
})->get();
String operations
| Prisma | Laravel | SQL |
|---|---|---|
contains: 'hello' | where('col', 'like', '%hello%') | LIKE '%hello%' |
startsWith: 'hello' | where('col', 'like', 'hello%') | LIKE 'hello%' |
endsWith: 'hello' | where('col', 'like', '%hello') | LIKE '%hello' |
Filtering by relation
// Prisma — posts where the author's name is "John"
const posts = await prisma.post.findMany({
where: {
author: {
name: 'John',
},
},
})
// Laravel
$posts = Post::whereHas('author', function ($query) {
$query->where('name', 'John');
})->get();
relationLoadStrategy — join vs query
By default, Prisma loads relations using a SQL JOIN (called the "join" strategy). But you can switch to a "query" strategy, which runs separate queries instead.
// Default — single JOIN query
const posts = await prisma.post.findMany({
relationLoadStrategy: 'join', // default
include: { author: true },
})
// → 1 SQL query with JOIN
// Separate queries
const posts = await prisma.post.findMany({
relationLoadStrategy: 'query',
include: { author: true },
})
// → 2 SQL queries: one for posts, one for authors
When to use each strategy
| Strategy | How it works | Best for |
|---|---|---|
"join" (default) | Single query with LEFT JOIN | Small-to-medium relations, simple queries |
"query" | Separate queries (like Laravel with()) | Large datasets, many relations, or when JOIN creates too many rows |
The "query" strategy is closer to how Laravel's with() works — it runs SELECT * FROM users WHERE id IN (1, 2, 3) as a separate query rather than joining.
Laravel equivalent
// Laravel always uses the "query" strategy by default
$posts = Post::with('author')->get();
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
When does the "query" strategy outperform "join"?
Consider a post with 100 comments, each comment having an author. With a JOIN, the database returns 100 rows with duplicated post data. With separate queries, you get the post once and the comments in a second query — less data transferred, less memory used.
Prisma vs Laravel — Quick Reference
| Concept | Laravel | Prisma |
|---|---|---|
| Relationship definition | Model class methods (hasMany, belongsTo) | schema.prisma relations |
| Eager loading | with('author') | include: { author: true } |
| Lazy loading | $post->author (automatic) | Not supported (by design) |
| Select specific fields | select('id', 'name') | select: { id: true, name: true } |
| Filter (where) | where('col', val) | where: { col: val } |
| Order by | orderBy('col', 'desc') | orderBy: { col: 'desc' } |
| Limit | take(10) | take: 10 |
| Offset | skip(10) | skip: 10 |
| Nested eager loading | with('author.posts') | include: { author: { include: { posts: true } } } |
| Filter by relation | whereHas('author', ...) | where: { author: { name: 'John' } } |
| Count | Post::count() | prisma.post.count() |
| Find by ID | Post::find(1) | prisma.post.findUnique({ where: { id: 1 } }) |
| Find or fail | Post::findOrFail(1) | prisma.post.findUniqueOrThrow({ where: { id: 1 } }) |
| Create | Post::create([...]) | prisma.post.create({ data: { ... } }) |
| Update | $post->update([...]) | prisma.post.update({ where: { id: 1 }, data: { ... } }) |
| Delete | $post->delete() | prisma.post.delete({ where: { id: 1 } }) |
Best Practices
1. Use select instead of include in API routes
In production APIs, avoid fetching columns you do not need. This reduces JSON payload size and improves response time:
// ❌ Fetches all 15+ columns from Post and all columns from User
const posts = await prisma.post.findMany({
include: { author: true },
})
// ✅ Fetches only what the frontend needs
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
id: true,
name: true,
},
},
},
})
2. Always use include or select instead of manual joins
Never run two separate Prisma queries and manually merge results. Let Prisma handle the join:
// ❌ Manual — two queries, manual merging
const posts = await prisma.post.findMany()
const users = await prisma.user.findMany()
const merged = posts.map((p) => ({
...p,
author: users.find((u) => u.id === p.authorId),
}))
// ✅ Let Prisma handle it
const posts = await prisma.post.findMany({
include: { author: true },
})
3. Use findUniqueOrThrow for detail pages
When fetching a single record that must exist (like a detail page), use findUniqueOrThrow. It throws a clear error if the record is missing, avoiding null checks:
// ❌ Returns null if not found — you must handle it
const post = await prisma.post.findUnique({
where: { id: 1 },
})
// ✅ Throws PrismaClientKnownRequestError if not found
const post = await prisma.post.findUniqueOrThrow({
where: { id: 1 },
include: { author: true },
})
4. Consider relationLoadStrategy: 'query' for heavy pages
If a page loads many relations (e.g., post + author + comments + tags), switching from "join" to "query" can reduce memory usage because the database does not duplicate rows across joins.
Summary & Key Takeaways
| Concept | What it means |
|---|---|
| Explicit loading | Prisma never loads relations automatically — you must use include or select |
include | Fetches the full related object — equivalent to Laravel with() |
select | Fetches specific fields only — reduces payload and improves performance |
| No lazy loading | Unlike Laravel, Prisma has no hidden queries — N+1 is impossible by design |
relationLoadStrategy | Controls whether relations are loaded via JOIN or separate queries |
where with relations | Filter by related model fields — equivalent to Laravel whereHas() |
What is Next
In Step 10, we will build the post detail page using a dynamic route app/posts/[id]/page.tsx. Clicking a post title will navigate to /posts/1, /posts/2, etc., showing the full post with its author.