Database & ORMs: TypeORM meets Eloquent
Overview
If you've used TypeORM, Prisma, or Sequelize, you'll find Laravel's Eloquent ORM refreshingly elegant. Eloquent uses the Active Record pattern (like ActiveRecord in Rails) rather than Data Mapper (like TypeORM), resulting in cleaner, more intuitive code.
Learning Objectives
By the end of this chapter, you'll be able to:
- ✅ Define models with Eloquent
- ✅ Write database migrations
- ✅ Query databases efficiently
- ✅ Define relationships (one-to-many, many-to-many)
- ✅ Use eager loading to avoid N+1 queries
- ✅ Seed databases with test data
- ✅ Apply database best practices
Code Examples
📁 View Code Examples on GitHub
This chapter includes comprehensive Eloquent ORM examples:
01-eloquent-basics.php- Complete guide to Eloquent (models, CRUD, relationships, scopes, accessors)
Setup a Laravel project to run these examples:
composer create-project laravel/laravel eloquent-demo
cd eloquent-demo
# Review the code examples
cat ../code/php-typescript-developers/chapter-14/01-eloquent-basics.php
# Create migrations and models as shown in examples
php artisan make:model User -m
php artisan migrateORM Comparison
| Feature | TypeORM | Eloquent |
|---|---|---|
| Pattern | Data Mapper | Active Record |
| Syntax | Verbose | Concise |
| Query Builder | QueryBuilder | Fluent methods |
| Relationships | Decorators | Methods |
| Migrations | CLI generated | Artisan generated |
| Seeding | Manual | Built-in |
Model Definition
TypeORM Entity
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
@Column({ default: true })
isActive: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}Eloquent Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model {
protected $fillable = ['name', 'email', 'is_active'];
protected $casts = [
'is_active' => 'boolean',
'created_at' => 'datetime',
];
}Eloquent conventions:
- Table name:
users(pluralized, snake_case) - Primary key:
id(auto-increment) - Timestamps:
created_at,updated_at(automatic)
Migrations
TypeORM Migration
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class CreateUsersTable1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{ name: 'id', type: 'int', isPrimary: true, isGenerated: true },
{ name: 'name', type: 'varchar' },
{ name: 'email', type: 'varchar', isUnique: true },
{ name: 'isActive', type: 'boolean', default: true },
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}Laravel Migration
php artisan make:migration create_users_table<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->boolean('is_active')->default(true);
$table->timestamps(); // created_at, updated_at
});
}
public function down(): void {
Schema::dropIfExists('users');
}
};Run migrations:
php artisan migrate
php artisan migrate:rollback
php artisan migrate:fresh # Drop all tables and re-runBasic CRUD Operations
TypeORM
// Create
const user = userRepository.create({
name: 'Alice',
email: 'alice@example.com',
});
await userRepository.save(user);
// Read
const users = await userRepository.find();
const user = await userRepository.findOne({ where: { id: 1 } });
// Update
user.name = 'Alice Updated';
await userRepository.save(user);
// Delete
await userRepository.remove(user);Eloquent
<?php
// Create
$user = User::create([
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// Read
$users = User::all();
$user = User::find(1);
$user = User::where('email', 'alice@example.com')->first();
// Update
$user->update(['name' => 'Alice Updated']);
// Or
$user->name = 'Alice Updated';
$user->save();
// Delete
$user->delete();So much cleaner!
Query Building
TypeORM QueryBuilder
const users = await userRepository
.createQueryBuilder('user')
.where('user.isActive = :active', { active: true })
.andWhere('user.age > :age', { age: 18 })
.orderBy('user.name', 'ASC')
.take(10)
.getMany();Eloquent Query Builder
<?php
$users = User::where('is_active', true)
->where('age', '>', 18)
->orderBy('name')
->limit(10)
->get();
// Or more complex
$users = User::query()
->where(function ($query) {
$query->where('role', 'admin')
->orWhere('role', 'moderator');
})
->whereNotNull('email_verified_at')
->get();Relationships
One-to-Many
TypeORM:
// User.ts
@Entity()
export class User {
@OneToMany(() => Post, post => post.user)
posts: Post[];
}
// Post.ts
@Entity()
export class Post {
@ManyToOne(() => User, user => user.posts)
@JoinColumn()
user: User;
@Column()
userId: number;
}
// Usage
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['posts'],
});Eloquent:
<?php
// User.php
class User extends Model {
public function posts() {
return $this->hasMany(Post::class);
}
}
// Post.php
class Post extends Model {
public function user() {
return $this->belongsTo(User::class);
}
}
// Usage
$user = User::with('posts')->find(1);
foreach ($user->posts as $post) {
echo $post->title;
}Many-to-Many
TypeORM:
@Entity()
export class User {
@ManyToMany(() => Role)
@JoinTable()
roles: Role[];
}
// Usage
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});Eloquent:
<?php
class User extends Model {
public function roles() {
return $this->belongsToMany(Role::class);
}
}
// Usage
$user = User::with('roles')->find(1);
// Attach/Detach
$user->roles()->attach($roleId);
$user->roles()->detach($roleId);
$user->roles()->sync([1, 2, 3]);Pivot table convention: role_user (alphabetically ordered)
Eager Loading (Avoiding N+1)
TypeORM
// ❌ N+1 Problem
const users = await userRepository.find();
for (const user of users) {
// Each iteration makes a query!
const posts = await user.posts;
}
// ✅ Eager Loading
const users = await userRepository.find({
relations: ['posts'],
});Eloquent
<?php
// ❌ N+1 Problem
$users = User::all();
foreach ($users as $user) {
// Each iteration makes a query!
echo $user->posts->count();
}
// ✅ Eager Loading
$users = User::with('posts')->get();
foreach ($users as $user) {
echo $user->posts->count(); // No extra queries!
}
// Multiple relationships
$users = User::with(['posts', 'roles'])->get();
// Nested relationships
$users = User::with('posts.comments')->get();Database Seeding
TypeORM (Manual)
import { Factory, Seeder } from 'typeorm-seeding';
export default class CreateUsers implements Seeder {
public async run(factory: Factory): Promise<void> {
await factory(User)().createMany(10);
}
}Laravel Factories & Seeders
Create Factory:
php artisan make:factory UserFactory<?php
// database/factories/UserFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory {
public function definition(): array {
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => bcrypt('password'),
];
}
}Create Seeder:
php artisan make:seeder UserSeeder<?php
// database/seeders/UserSeeder.php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder {
public function run(): void {
// Create 10 users
User::factory()->count(10)->create();
// Create specific user
User::factory()->create([
'name' => 'Admin User',
'email' => 'admin@example.com',
]);
}
}Run seeder:
php artisan db:seed
php artisan db:seed --class=UserSeederAdvanced Queries
Aggregates
TypeORM:
const count = await userRepository.count();
const avg = await userRepository
.createQueryBuilder()
.select('AVG(age)', 'average')
.getRawOne();Eloquent:
<?php
$count = User::count();
$avg = User::avg('age');
$max = User::max('age');
$sum = User::sum('balance');
// With conditions
$activeCount = User::where('is_active', true)->count();Raw Queries
TypeORM:
const users = await userRepository.query(
'SELECT * FROM users WHERE age > ?',
[18]
);Eloquent:
<?php
use Illuminate\Support\Facades\DB;
$users = DB::select('SELECT * FROM users WHERE age > ?', [18]);
// Or with query builder
$users = DB::table('users')
->where('age', '>', 18)
->get();Transactions
TypeORM
await connection.transaction(async manager => {
await manager.save(user);
await manager.save(post);
});Eloquent
<?php
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($user, $post) {
$user->save();
$post->save();
});
// Or manual
DB::beginTransaction();
try {
$user->save();
$post->save();
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}Soft Deletes
TypeORM
@Entity()
export class User {
@DeleteDateColumn()
deletedAt?: Date;
}
await userRepository.softDelete(id);
await userRepository.restore(id);Eloquent
<?php
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model {
use SoftDeletes;
}
// Usage
$user->delete(); // Soft delete
$user->restore(); // Restore
$user->forceDelete(); // Permanent delete
// Query with trashed
User::withTrashed()->find(1);
User::onlyTrashed()->get();Migration:
$table->softDeletes(); // Adds deleted_at columnAccessors & Mutators
Eloquent (Attribute Casting)
<?php
class User extends Model {
// Accessor (get)
public function getFullNameAttribute(): string {
return "{$this->first_name} {$this->last_name}";
}
// Mutator (set)
public function setEmailAttribute(string $value): void {
$this->attributes['email'] = strtolower($value);
}
}
// Usage
$user->full_name; // Calls accessor
$user->email = 'ALICE@EXAMPLE.COM'; // Calls mutator, stores lowercaseModern syntax (PHP 8.1+):
<?php
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model {
protected function fullName(): Attribute {
return Attribute::make(
get: fn() => "{$this->first_name} {$this->last_name}",
);
}
protected function email(): Attribute {
return Attribute::make(
set: fn($value) => strtolower($value),
);
}
}Practical Example: Blog System
<?php
// Models
class User extends Model {
public function posts() {
return $this->hasMany(Post::class);
}
public function comments() {
return $this->hasMany(Comment::class);
}
}
class Post extends Model {
protected $fillable = ['title', 'body', 'user_id'];
public function user() {
return $this->belongsTo(User::class);
}
public function comments() {
return $this->hasMany(Comment::class);
}
public function tags() {
return $this->belongsToMany(Tag::class);
}
}
class Comment extends Model {
public function post() {
return $this->belongsTo(Post::class);
}
public function user() {
return $this->belongsTo(User::class);
}
}
class Tag extends Model {
public function posts() {
return $this->belongsToMany(Post::class);
}
}
// Queries
// Get user with posts and comments
$user = User::with(['posts.comments', 'comments'])->find(1);
// Get posts with user, comments, and tags
$posts = Post::with('user', 'comments.user', 'tags')->get();
// Create post with tags
$post = Post::create([
'title' => 'My Post',
'body' => 'Content here',
'user_id' => 1,
]);
$post->tags()->attach([1, 2, 3]);
// Get posts by tag
$taggedPosts = Tag::find(1)->posts;Key Takeaways
- Eloquent is more concise than TypeORM - fewer lines, clearer intent
- Active Record pattern feels more natural than TypeORM's Data Mapper
- Migrations are cleaner with Blueprint DSL - no verbose table definitions
- Relationships are simpler to define - just method definitions, no decorators
- Factories & seeders built-in - no external library needed
- Query builder is intuitive and fluent - chains naturally like Eloquent
- Soft deletes trivial to implement - one trait, one column
- Eager loading with
with()prevents N+1 queries automatically - Convention over configuration - table names, foreign keys inferred automatically
- Accessors and mutators provide clean data transformation (getters/setters on steroids)
- Model events (creating, created, updating, etc.) for lifecycle hooks
- Query scopes allow reusable query logic - like TypeORM's query builder methods but cleaner
Comparison Summary
| Feature | TypeORM | Eloquent |
|---|---|---|
| Model Definition | Decorators | Class methods |
| Queries | repository.find() | Model::get() |
| Relationships | Decorators | Methods |
| Eager Loading | relations: [] | with() |
| Migrations | Classes | Blueprint DSL |
| Seeding | External library | Built-in |
| Soft Deletes | @DeleteDateColumn | SoftDeletes trait |
Next Steps
Now let's build a complete full-stack application with Laravel and Inertia.js!
Next Chapter: 15: Full-Stack: Inertia.js (React/Vue + Laravel)
Resources
Questions or feedback? Open an issue on GitHub