Migrating from Laravel to Node.js: A Practical Guide with Sutando
If you're a Laravel developer considering a move to Node.js, the biggest concern is usually leaving Eloquent behind. With Sutando, you don't have to. This…
If you’re a Laravel developer considering a move to Node.js, the biggest concern is usually leaving Eloquent behind. With Sutando, you don’t have to. This guide walks through migrating a Laravel application to Node.js, mapping each concept from Eloquent to Sutando.
Why Migrate from Laravel to Node.js?
Common reasons include:
- Real-time features: WebSocket, SSE, streaming — Node.js’s event loop handles these natively
- Full-stack TypeScript: Share types between frontend and backend
- Serverless/Edge: Deploy to Cloudflare Workers, Vercel Edge, or AWS Lambda
- Team skills: Your team knows JavaScript better than PHP
- Ecosystem: npm’s ecosystem is larger than Composer’s for certain domains
Concept Mapping: Laravel → Node.js + Sutando
| Laravel | Node.js + Sutando |
|---|---|
| Eloquent Model | Sutando Model class |
| Migration | Sutando Schema Builder |
| Seeder | Not supported |
| Factory | Not supported |
| Route | Express / Fastify route |
| Middleware | Express middleware |
| Blade | React / Vue / API-only |
| Service Container | Manual DI / framework-specific |
| Artisan CLI | Custom npm scripts |
Eloquent Model → Sutando Model
Laravel (PHP)
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model {
protected $table = 'users';
protected $casts = [
'is_admin' => 'boolean',
'metadata' => 'array',
];
protected $fillable = ['name', 'email', 'password'];
use SoftDeletes;
public function posts(): HasMany {
return $this->hasMany(Post::class);
}
public function scopeActive($query) {
return $query->where('active', true);
}
}
Sutando (TypeScript)
import { Model } from 'sutando';
class User extends Model {
table = 'users';
casts = {
is_admin: 'boolean',
metadata: 'json',
};
relationPosts() {
return this.hasMany(Post, 'user_id');
}
scopeActive(query) {
return query.where('active', true);
}
}
The mapping is nearly 1:1. The main differences:
protected $table→table =protected $casts→casts =$fillable→ no equivalent; filter attributes yourself before mass assignment- Relationship methods use
relationprefix - Scopes use
scopeprefix - No
usestatements for traits — soft deletes are configured differently
Query Comparison
Basic Query
// Laravel
$users = User::where('active', true)
->orderBy('created_at', 'desc')
->limit(10)
->get();
// Sutando
const users = await User.query()
.where('active', true)
.orderBy('created_at', 'desc')
.limit(10)
.get();
Eager Loading
// Laravel
$users = User::with('posts.comments')->get();
// Sutando
const users = await User.query().with('posts.comments').get();
Create
// Laravel
$user = User::create(['name' => 'Alice', 'email' => 'alice@example.com']);
// Sutando
const user = await User.query().create({ name: 'Alice', email: 'alice@example.com' });
Update
// Laravel
$user = User::find(1);
$user->name = 'Bob';
$user->save();
// Sutando
const user = await User.query().find(1);
user.name = 'Bob';
await user.save();
Model Events
// Laravel
User::creating(function ($user) {
$user->password = bcrypt($user->password);
});
// Sutando
User.creating(async (user) => {
user.password = await bcrypt.hash(user.password, 10);
});
Migration: Laravel → Sutando Schema Builder
Laravel Migration
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->boolean('active')->default(true);
$table->timestamps();
});
Sutando Schema Builder
await sutando.schema().createTable('users', table => {
table.increments('id').primary();
table.string('name');
table.string('email').unique();
table.string('password');
table.boolean('active').defaultTo(true);
table.timestamps();
});
Routes: Laravel → Express
Laravel
Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store']);
Route::get('/posts/{id}', [PostController::class, 'show']);
Express
app.get('/posts', async (req, res) => {
const posts = await Post.query().with('user').get();
res.json(posts);
});
app.post('/posts', async (req, res) => {
const post = await Post.query().create(req.body);
res.status(201).json(post);
});
app.get('/posts/:id', async (req, res) => {
const post = await Post.query().with('user', 'comments').find(req.params.id);
res.json(post);
});
Practical Example: Migrating a Blog
Let’s say you have a Laravel blog with User, Post, and Comment models. Here’s the full Sutando equivalent:
import { sutando, Model } from 'sutando';
import express from 'express';
// Database setup
sutando.addConnection({
client: 'mysql2',
connection: { host: '127.0.0.1', user: 'root', password: '', database: 'blog' }
});
// Models
class User extends Model {
table = 'users';
relationPosts() { return this.hasMany(Post, 'user_id'); }
relationComments() { return this.hasMany(Comment, 'user_id'); }
}
class Post extends Model {
table = 'posts';
casts = { published: 'boolean' };
relationUser() { return this.belongsTo(User, 'user_id'); }
relationComments() { return this.hasMany(Comment, 'post_id'); }
scopePublished(query) { return query.where('published', true); }
}
class Comment extends Model {
table = 'comments';
relationUser() { return this.belongsTo(User, 'user_id'); }
relationPost() { return this.belongsTo(Post, 'post_id'); }
}
// Express app
const app = express();
app.use(express.json());
app.get('/posts', async (req, res) => {
const posts = await Post.query()
.with('user', 'comments.user')
.published()
.orderBy('created_at', 'desc')
.limit(20)
.get();
res.json(posts);
});
app.post('/posts', async (req, res) => {
const post = await Post.query().create({
...req.body,
published: false,
});
res.status(201).json(post);
});
app.listen(3000);
What Doesn’t Map Directly
Some Laravel features need manual implementation in Node.js:
- Authentication: Laravel’s built-in auth → use JWT, Passport.js, or custom middleware
- Validation: Laravel’s FormRequest → use Zod, Joi, or express-validator
- Queue/Jobs: Laravel Queue → use BullMQ, or Cloudflare Queues
- Mail: Laravel Mail → use Nodemailer, or Cloudflare Email
- Artisan commands: → custom npm scripts or a CLI framework like Commander
Migration Strategy
- Keep the same database — Sutando works with existing MySQL/PostgreSQL schemas
- Migrate route by route — use Nginx as a reverse proxy to split traffic
- Start with read-only routes — they’re lowest risk
- Keep sessions in Redis — both PHP and Node can read from Redis
- Don’t rewrite everything at once — the strangler fig pattern works well
Conclusion
Migrating from Laravel to Node.js doesn’t mean abandoning Eloquent. Sutando brings the same Active Record experience to Node.js with nearly identical API. You can keep your database schema, migrate routes incrementally, and your team can be productive from day one.
Start with npm install sutando and check the documentation.