npx express-generator --no-view myapp && npm install or npx @nestjs/cli new myappworker_threads, child processes, or offload to a message queue. [src1]req.locals or res.locals. [src5]$_SESSION). Node.js has no built-in session mechanism. You must implement express-session with a Redis/Memcached store, or switch to JWT for stateless auth. [src4]| PHP Pattern | Node.js Equivalent | Example |
|---|---|---|
$_GET['param'] / $_POST['param'] | req.query.param / req.body.param | app.get('/users', (req, res) => { const id = req.query.id; }) |
PDO::query($sql) | Knex query builder or Prisma Client | const users = await knex('users').where('active', true) |
$_SESSION['user'] | express-session + Redis store or JWT | req.session.user = { id: 1, name: 'Alice' } |
middleware() via PHP frameworks | app.use(middleware) | app.use(express.json()) or app.use(cors()) |
move_uploaded_file() | multer middleware | upload.single('avatar') then req.file.path |
require_once 'template.php' | Template engine (EJS/Pug) or API-only | res.render('index', { title: 'Home' }) or res.json(data) |
cron + PHP script | node-cron or OS-level cron | cron.schedule('0 * * * *', () => { runJob() }) |
try/catch + throw | try/catch + async error middleware | app.use((err, req, res, next) => { res.status(500).json({error: err.message}) }) |
password_hash() / password_verify() | bcrypt package | const hash = await bcrypt.hash(password, 12) |
getenv('DB_HOST') / .env files | dotenv + process.env | require('dotenv').config(); const host = process.env.DB_HOST |
composer require package | npm install package | npm install express cors helmet |
json_encode() / json_decode() | JSON.stringify() / JSON.parse() | res.json({ status: 'ok' }) |
header('Content-Type: ...') | res.set() or res.type() | res.type('json') or res.set('X-Custom', 'value') |
http_response_code(404) | res.status(404) | res.status(404).json({ error: 'Not found' }) |
file_get_contents($url) | fetch() (built-in Node 18+) | const data = await fetch(url).then(r => r.json()) |
START
├── Is this a complete rewrite or incremental migration?
│ ├── COMPLETE REWRITE → New Node.js project (NestJS 11 for large teams, Express 5 for small, Fastify 5 for performance-critical)
│ └── INCREMENTAL ↓
├── Is the PHP app a monolith or already service-oriented?
│ ├── MONOLITH → Use strangler fig pattern: Nginx/HAProxy routes new endpoints to Node.js
│ └── SERVICE-ORIENTED → Replace services one at a time with Node.js equivalents
├── Does the PHP app use a framework (Laravel, Symfony)?
│ ├── LARAVEL → Map concepts: routes→Express, Eloquent→Prisma/Knex, middleware→Express middleware, Blade→EJS/API-only
│ ├── SYMFONY → Map concepts: controllers→NestJS controllers, Doctrine→Prisma, DI→NestJS DI
│ └── VANILLA PHP → Identify all entry points, convert each to an Express route
├── Is the workload CPU-intensive (image processing, PDF generation)?
│ ├── YES → Keep those in PHP or use worker threads / child processes in Node.js
│ └── NO (I/O-bound) ↓
├── Does the team know TypeScript?
│ ├── YES → Use NestJS 11 (structured, decorators, DI) — see NestJS example below
│ └── NO → Use Express 5 (minimal, fast to learn) — see Express example below
└── DEFAULT → Start with Express 5, migrate highest-traffic endpoints first
Catalog every PHP entry point, database query, session usage, and external API call. This converts a vague migration into discrete, estimable tasks. [src3]
# Count PHP files and entry points
find . -name '*.php' | wc -l
grep -rn 'function\s' --include='*.php' | wc -l
# Find all route definitions (Laravel)
grep -rn "Route::" --include='*.php' routes/ | wc -l
# Find all database queries
grep -rn 'query\|prepare\|execute\|PDO\|DB::' --include='*.php' | wc -l
# Find session usage
grep -rn '\$_SESSION\|session_start\|Session::' --include='*.php' | wc -l
Verify: You have a list of every endpoint, its HTTP method, database tables touched, and session state usage.
Initialize the Node.js application without removing the PHP app. Both run in parallel behind a reverse proxy. [src1, src2]
# Express 5 setup
mkdir node-backend && cd node-backend
npm init -y
npm install express@5 cors helmet dotenv
npm install -D typescript @types/express @types/node ts-node nodemon
# Or NestJS 11 setup
npx @nestjs/cli new node-backend --strict --package-manager npm
// src/app.ts — minimal Express 5 server
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import 'dotenv/config';
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Node.js server on port ${PORT}`));
Verify: curl http://localhost:3000/health returns {"status":"ok"}.
Set up Nginx to route traffic to either PHP or Node.js based on URL path. Migrated endpoints go to Node.js; everything else stays on PHP. This is the strangler fig pattern in action. [src3]
# Nginx config — strangler fig pattern
upstream php_backend { server 127.0.0.1:9000; }
upstream node_backend { server 127.0.0.1:3000; }
server {
listen 80;
# Migrated endpoints → Node.js
location /api/v2/ {
proxy_pass http://node_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Everything else → PHP (legacy)
location / {
try_files $uri $uri/ /index.php?$query_string;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Verify: /api/v2/health hits Node.js; /dashboard hits PHP.
Replace PHP PDO calls with Knex.js (closest to PDO) or Prisma 6 (type-safe ORM). Keep the same database — no need to migrate data. [src7]
// src/db.ts — Knex setup (familiar for PDO users)
import knex from 'knex';
import 'dotenv/config';
export const db = knex({
client: 'mysql2', // or 'pg' for PostgreSQL
connection: {
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
pool: { min: 2, max: 10 },
});
// PHP: $stmt = $pdo->prepare('SELECT * FROM users WHERE active = ?');
// Node.js:
const users = await db('users').where('active', true).select('*');
Verify: Query returns the same data as the PHP app's database calls.
Replace PHP sessions with JWT tokens or express-session backed by Redis. JWT is preferred for API-first architectures; express-session is the closest PHP-like experience. [src4, src5]
// JWT authentication middleware
import jwt from 'jsonwebtoken';
export function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
Verify: Login returns a JWT token; authenticated requests pass the middleware.
Convert PHP controller methods to Express route handlers or NestJS controllers. Separate business logic into service modules. [src4, src6]
// Express route replacing PHP UserController
import { Router } from 'express';
import { db } from '../db';
import { authenticate } from '../middleware/auth';
const router = Router();
router.get('/', authenticate, async (req, res) => {
const page = Number(req.query.page) || 1;
const limit = 20;
const offset = (page - 1) * limit;
const [users, [{ count }]] = await Promise.all([
db('users').select('id', 'name', 'email').limit(limit).offset(offset),
db('users').count('* as count'),
]);
res.json({ data: users, meta: { page, limit, total: Number(count) } });
});
export default router;
Verify: curl /api/v2/users returns the same user list as the PHP endpoint.
Replace PHP's error handling with Express async error middleware. Add structured logging and process management. [src2, src3]
// Express 5 catches async errors automatically
import pino from 'pino';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
app.use((err, req, res, next) => {
logger.error({ err, method: req.method, url: req.url }, 'Unhandled error');
res.status(500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
});
});
Verify: Trigger an intentional error — response is structured JSON and log contains the stack trace.
Full script: express-5-typescript-complete-rest-api-replacing-a.ts (59 lines)
// Input: A PHP Laravel-style CRUD controller
// Output: Equivalent Express 5 + Knex.js REST API with full error handling
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import knex from 'knex';
import 'dotenv/config';
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const db = knex({ client: 'pg', connection: process.env.DATABASE_URL, pool: { min: 2, max: 10 } });
// GET /api/products — replaces ProductController@index
app.get('/api/products', async (req, res) => {
const { category, min_price, max_price, page = '1' } = req.query;
let query = db('products').select('*');
if (category) query = query.where('category', category as string);
if (min_price) query = query.where('price', '>=', Number(min_price));
if (max_price) query = query.where('price', '<=', Number(max_price));
const limit = 20;
const offset = (Number(page) - 1) * limit;
const products = await query.limit(limit).offset(offset);
res.json({ data: products, meta: { page: Number(page) } });
});
// Express 5 catches async rejections automatically
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(3000, () => console.log('Server on :3000'));
Full script: nestjs-typescript-structured-migration-with-module.ts (53 lines)
// Input: A PHP Symfony/Laravel controller with DI, services, repositories
// Output: NestJS equivalent with same architectural patterns
// products.controller.ts
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { ProductsService } from './products.service';
import { AuthGuard } from '../auth/auth.guard';
@Controller('api/products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll(@Query('page') page = '1', @Query('category') category?: string) {
return this.productsService.findAll({ page: Number(page), category });
}
@Post()
@UseGuards(AuthGuard)
create(@Body() body: { name: string; price: number; category?: string }) {
return this.productsService.create(body);
}
}
// products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectKnex, Knex } from 'nestjs-knex';
@Injectable()
export class ProductsService {
constructor(@InjectKnex() private readonly knex: Knex) {}
async findAll({ page, category }: { page: number; category?: string }) {
let query = this.knex('products').select('*');
if (category) query = query.where('category', category);
const data = await query.limit(20).offset((page - 1) * 20);
return { data, meta: { page } };
}
async create(product: { name: string; price: number; category?: string }) {
const [created] = await this.knex('products').insert(product).returning('*');
return { data: created };
}
}
Full script: database-migration-converting-php-pdo-schema-manag.ts (33 lines)
// Input: PHP Laravel migration for users table
// Output: Knex.js migration file equivalent
// migrations/20260217_create_users_table.ts
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('name', 255).notNullable();
table.string('email', 255).notNullable().unique();
table.string('password_hash', 255).notNullable();
table.string('role', 50).defaultTo('user');
table.boolean('active').defaultTo(true);
table.timestamps(true, true); // created_at, updated_at
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('users');
}
// Run: npx knex migrate:latest --knexfile knexfile.ts
// PHP equivalent: php artisan migrate
// BAD — Synchronous file read blocks ALL concurrent requests
// PHP does this safely because each request has its own process
const data = fs.readFileSync('/path/to/large-file.csv', 'utf8'); // Blocks!
const parsed = parseCSV(data); // CPU-intensive — blocks further!
// GOOD — Async I/O + worker thread for CPU-bound work
const data = await fs.promises.readFile('/path/to/large-file.csv', 'utf8'); // Non-blocking
const parsed = await runInWorker('parseCSV', data); // Off main thread
// BAD — Global variable shared across ALL concurrent requests
let currentUser = null; // Race condition!
app.use((req, res, next) => {
currentUser = getUserFromToken(req.headers.authorization);
next();
});
// GOOD — Data scoped to the request object
app.use((req, res, next) => {
req.currentUser = getUserFromToken(req.headers.authorization);
next();
});
// BAD — PHP-style: new connection per request
app.get('/users', async (req, res) => {
const conn = await mysql.createConnection({ host: 'localhost', database: 'mydb' });
const [rows] = await conn.execute('SELECT * FROM users');
await conn.end(); // Created and destroyed every request
res.json(rows);
});
// GOOD — Connection pool reuses connections
const db = knex({ client: 'mysql2', connection: process.env.DATABASE_URL, pool: { min: 2, max: 10 } });
app.get('/users', async (req, res) => {
const users = await db('users').select('*'); // Pool manages connections
res.json(users);
});
// BAD — Silent catch like PHP's @file_get_contents()
try {
const data = await fetchExternalAPI();
res.json(data);
} catch (e) {
res.json({ data: [] }); // No logging, no monitoring
}
// GOOD — Structured error handling with logging
try {
const data = await fetchExternalAPI();
res.json(data);
} catch (error) {
logger.error({ err: error, url: req.url }, 'External API failed');
res.status(502).json({ error: 'Upstream service unavailable' });
}
// BAD — Path traversal vulnerability (like PHP include($_GET['page']))
app.get('/:page', (req, res) => {
const module = require(`./pages/${req.params.page}`); // Dangerous!
res.send(module.render());
});
// GOOD — Explicit route mapping
const pages = { home: () => import('./pages/home'), about: () => import('./pages/about') };
app.get('/:page', async (req, res) => {
const loader = pages[req.params.page];
if (!loader) return res.status(404).json({ error: 'Not found' });
const module = await loader();
res.send(module.render());
});
worker_threads for CPU work or offload to a message queue (BullMQ, RabbitMQ). [src1]req.locals or res.locals. [src5]util.promisify() to convert callback APIs, or use only async/await. Express 5 natively catches async rejections. [src2]npm install -g pm2 && pm2 start app.js -i max. [src4]process.on('uncaughtException') and process.on('unhandledRejection') handlers, plus use PM2 for auto-restart. [src1]setImmediate() vs process.nextTick() intentionally. [src1]# Check Node.js version (must be 20+ for Active LTS, 22+ for Current LTS)
node --version
# Verify npm packages installed correctly
npm ls --depth=0
# Test database connection from Node.js
node -e "require('knex')({client:'pg',connection:process.env.DATABASE_URL}).raw('SELECT 1').then(()=>console.log('DB OK')).catch(console.error)"
# Check for event loop blocking (should show <10ms delay)
node -e "setInterval(() => { const s=Date.now(); setImmediate(() => console.log('lag:', Date.now()-s, 'ms')); }, 1000)"
# Run Express app with auto-reload
npx nodemon --exec ts-node src/app.ts
# Check PM2 process status in production
pm2 status && pm2 logs --lines 50
# Verify Nginx proxy routing
curl -I http://localhost/api/v2/health
curl -I http://localhost/dashboard
# Compare PHP and Node.js endpoint responses
diff <(curl -s http://localhost/api/v1/users) <(curl -s http://localhost/api/v2/users)
# Profile Node.js app for event loop delays
node --prof src/app.js # Creates isolate-*.log, then: node --prof-process isolate-*.log
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Node.js 22.x (2024) | Current LTS (Jod) | require() for ESM (experimental), WebSocket API built-in | Stable V8 12.x, top-level await, built-in fetch stable, Watch Mode stable |
| Node.js 20.x (2023) | Active LTS | Permission model, test runner stable | Stable fetch, WebCrypto |
| Node.js 18.x (2022) | EOL (Apr 2025) | Built-in fetch (experimental) | Minimum for Express 5 — upgrade to 20+ immediately |
| Express 5.1 (2025) | Current | Async error handling, dropped app.del() | Automatic promise rejection forwarding |
| Express 4.x (2014) | Maintenance | — | Upgrade to 5.x: remove app.del(), update path patterns |
| NestJS 11 (2025) | Current | JSON logging built-in, IntrinsicException, improved startup perf | Requires Node.js 18+ (recommend 20+), Apollo Server v4 for GraphQL |
| NestJS 10 (2023) | Maintenance | SWC compiler default | Requires Node.js 16+ |
| Prisma 6.x (2025) | Current | Query Compiler replaces Rust engine, ESM support | Leaner architecture, schema splitting GA, min Node.js 18.18+ |
| Knex 3.x (2023) | Current | ESM-first, dropped Node <14 | Use type: "module" in package.json |
| PHP 8.4 (2024) | Current (source side) | Property hooks, new DOM API | JIT improvements, 2x faster than PHP 7.4 on some workloads |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| I/O-heavy app (API gateway, real-time, streaming) | CPU-heavy computation (video encoding, ML inference) | Go, Rust, or keep PHP with worker queues |
| Team knows JavaScript/TypeScript well | Team is highly productive in PHP with no pain points | Stay on PHP 8.4 (JIT, fibers, property hooks) |
| Building microservices or API-first architecture | Simple CMS or blog (WordPress, Drupal) | Keep PHP + WordPress, or headless CMS |
| Need real-time features (WebSocket, SSE) | Traditional server-rendered MVC app | Laravel/Symfony with Livewire/Hotwire |
| Unifying frontend and backend in one language | PHP codebase is small (<5K LOC) and stable | Incremental PHP modernization (PHP 8.4, PSR-15) |
| High-concurrency API (>10K concurrent connections) | Batch processing or cron jobs only | Keep PHP scripts, use Node.js only for the API layer |
cluster module, PM2 in cluster mode, or Docker with multiple containers. PHP-FPM handles this transparently.express-async-errors package that was required in Express 4.include/require execute at runtime; Node.js require() caches modules after first load. Configuration changes require a server restart.express-session with Redis, or switch to JWT.PDO::ERRMODE_EXCEPTION throws on query errors. Knex always returns Promises — ensure every database call has .catch() or try/catch.$_FILES automatically. In Node.js, you need multer or busboy middleware — the raw request body is a stream.