Skip to content

14: Database & ORMs - TypeORM meets Eloquent

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.

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

📁 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:

Terminal window
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 migrate
FeatureTypeORMEloquent
PatternData MapperActive Record
SyntaxVerboseConcise
Query BuilderQueryBuilderFluent methods
RelationshipsDecoratorsMethods
MigrationsCLI generatedArtisan generated
SeedingManualBuilt-in
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;
}
<?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)
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');
}
}
Terminal window
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:

Terminal window
php artisan migrate
php artisan migrate:rollback
php artisan migrate:fresh # Drop all tables and re-run
// 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);
<?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!

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();
<?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();

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:

User.php
<?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;
}

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)

// ❌ 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'],
});
<?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();
import { Factory, Seeder } from 'typeorm-seeding';
export default class CreateUsers implements Seeder {
public async run(factory: Factory): Promise<void> {
await factory(User)().createMany(10);
}
}

Create Factory:

Terminal window
php artisan make:factory UserFactory
database/factories/UserFactory.php
<?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:

Terminal window
php artisan make:seeder UserSeeder
database/seeders/UserSeeder.php
<?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:

Terminal window
php artisan db:seed
php artisan db:seed --class=UserSeeder

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();

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();
await connection.transaction(async manager => {
await manager.save(user);
await manager.save(post);
});
<?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;
}
@Entity()
export class User {
@DeleteDateColumn()
deletedAt?: Date;
}
await userRepository.softDelete(id);
await userRepository.restore(id);
<?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 column
<?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 lowercase

Modern 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),
);
}
}
<?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;
  1. Eloquent is more concise than TypeORM - fewer lines, clearer intent
  2. Active Record pattern feels more natural than TypeORM’s Data Mapper
  3. Migrations are cleaner with Blueprint DSL - no verbose table definitions
  4. Relationships are simpler to define - just method definitions, no decorators
  5. Factories & seeders built-in - no external library needed
  6. Query builder is intuitive and fluent - chains naturally like Eloquent
  7. Soft deletes trivial to implement - one trait, one column
  8. Eager loading with with() prevents N+1 queries automatically
  9. Convention over configuration - table names, foreign keys inferred automatically
  10. Accessors and mutators provide clean data transformation (getters/setters on steroids)
  11. Model events (creating, created, updating, etc.) for lifecycle hooks
  12. Query scopes allow reusable query logic - like TypeORM’s query builder methods but cleaner
FeatureTypeORMEloquent
Model DefinitionDecoratorsClass methods
Queriesrepository.find()Model::get()
RelationshipsDecoratorsMethods
Eager Loadingrelations: []with()
MigrationsClassesBlueprint DSL
SeedingExternal libraryBuilt-in
Soft Deletes@DeleteDateColumnSoftDeletes trait

Now let’s build a complete full-stack application with Laravel and Inertia.js!

Next Chapter: 15: Full-Stack: Inertia.js (React/Vue + Laravel)


Questions or feedback? Open an issue on GitHub