How to Migrate a PHP Backend to Node.js
How do I migrate a PHP backend to Node.js?
TL;DR
- Bottom line: Migrate incrementally using the strangler fig pattern — extract PHP endpoints into Node.js services one at a time behind a reverse proxy, converting synchronous PHP patterns to async Node.js equivalents with Express 5 or NestJS 11.
- Key tool/command:
npx express-generator --no-view myapp && npm installornpx @nestjs/cli new myapp - Watch out for: Treating PHP's synchronous, request-per-process model as if it maps 1:1 to Node.js — Node.js is single-threaded and event-driven, so blocking the event loop freezes all concurrent requests.
- Works with: Node.js 22 (Maintenance LTS) / 24 (Active LTS, Krypton, since Oct 2025), Express 5.x, NestJS 11+ (NestJS 12 alpha Q2 2026 — full ESM + Vitest/oxlint/Rspack), Fastify 5+, PHP 7.4–8.4 (source side). [src8, src9]
Constraints
- Async paradigm shift: Node.js is single-threaded and event-loop-driven. CPU-intensive synchronous operations (image processing, PDF generation, tight loops) block ALL concurrent requests. Use
worker_threads, child processes, or offload to a message queue. [src1] - No request isolation: PHP gives each request its own process with isolated memory. Node.js shares module-level state across all concurrent requests. Never store request-scoped data in global or module-level variables — use
req.localsorres.locals. [src5] - Session handling redesign required: PHP sessions use server-side files by default (
$_SESSION). Node.js has no built-in session mechanism. You must implementexpress-sessionwith a Redis/Memcached store, or switch to JWT for stateless auth. [src4] - Hosting architecture differs: PHP runs behind Apache/Nginx + PHP-FPM, which automatically manages worker processes. Node.js requires a process manager (PM2, Docker, systemd) for zero-downtime restarts, multi-core utilization, and crash recovery. [src3]
- Express 5.x minimum Node.js 18: Verify runtime version before starting. Node.js 18 reached EOL April 2025; Node.js 20 and 22 are in maintenance. Target Node.js 24 (Krypton) — the Active LTS as of October 2025. [src2, src8]
Quick Reference
| 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()) |
Decision Tree
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
Step-by-Step Guide
1. Audit the PHP codebase and map endpoints
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.
2. Set up the Node.js project alongside PHP
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"}.
3. Configure reverse proxy for gradual traffic shifting
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.
4. Migrate the database layer
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.
5. Convert authentication and session management
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.
6. Migrate routes and business logic
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.
7. Set up error handling, logging, and monitoring
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.
Code Examples
Express 5 (TypeScript): Complete REST API replacing a PHP CRUD backend
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'));
NestJS 11 (TypeScript): Structured migration with modules, controllers, services
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 };
}
}
Database Migration: Converting PHP/PDO schema management to Knex migrations
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
Anti-Patterns
Wrong: Blocking the event loop with synchronous operations
// 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!
Correct: Use async I/O and worker threads for CPU work
// 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
Wrong: Using PHP-style global state for request data
// BAD — Global variable shared across ALL concurrent requests
let currentUser = null; // Race condition!
app.use((req, res, next) => {
currentUser = getUserFromToken(req.headers.authorization);
next();
});
Correct: Attach request-scoped data to the request object
// GOOD — Data scoped to the request object
app.use((req, res, next) => {
req.currentUser = getUserFromToken(req.headers.authorization);
next();
});
Wrong: No connection pooling (new DB connection per query)
// 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);
});
Correct: Use a connection pool
// 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);
});
Wrong: Silently swallowing errors (PHP @ operator style)
// 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
}
Correct: Log errors, return appropriate status codes
// 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' });
}
Wrong: Dynamic requires based on user input
// 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());
});
Correct: Explicit route registration with allowlist
// 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());
});
Common Pitfalls
- Blocking the event loop with CPU-intensive code: PHP handles this transparently (one process per request), but Node.js serves ALL requests on a single thread. Fix: Use
worker_threadsfor CPU work or offload to a message queue (BullMQ, RabbitMQ). [src1] - Assuming each request has isolated memory: PHP globals are per-request. In Node.js, module-level variables are shared across ALL concurrent requests. Fix: Never store request-scoped data in module-level variables — use
req.localsorres.locals. [src5] - Mixing callbacks and Promises: PHP doesn't have callbacks. Fix: Use
util.promisify()to convert callback APIs, or use onlyasync/await. Express 5 natively catches async rejections. [src2] - Not setting up process management: PHP-FPM manages worker processes automatically. Node.js needs PM2 or Docker for zero-downtime restarts. Fix:
npm install -g pm2 && pm2 start app.js -i max. [src4] - Migrating everything at once: Big-bang rewrites fail. Fix: Use the strangler fig pattern — migrate one endpoint at a time behind a reverse proxy. [src3]
- Forgetting to handle uncaught exceptions: PHP crashes per-request; Node.js crashes the entire server. Fix: Add
process.on('uncaughtException')andprocess.on('unhandledRejection')handlers, plus use PM2 for auto-restart. [src1] - Ignoring event loop and microtask queue ordering: PHP executes top-to-bottom. Node.js has event loop phases. Fix: Read the Node.js event loop documentation and use
setImmediate()vsprocess.nextTick()intentionally. [src1] - Underestimating PHP 8.4 improvements: PHP 8.4 has property hooks, JIT improvements, and fibers. If the only driver is “PHP is slow,” benchmark first — PHP 8.4 can be 2x faster than PHP 7.4. Migration may not be necessary. [src5]
Diagnostic Commands
# Check Node.js version (target 24.x = Active LTS Krypton, 22.x = Maintenance 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 History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Node.js 24.x (2025) | Active LTS (Krypton) since Oct 2025 | OpenSSL 3.5 (security level 2), V8 13.x, native TypeScript type-stripping stable, npm v11 (65% faster installs) | Recommended runtime for new migrations. EOL April 2028. |
| Node.js 22.x (2024) | Maintenance 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) | Maintenance | 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 22+ or 24 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 12 (alpha, Q3 2026) | Pre-release | Full ESM migration (drops CJS), Vitest replaces Jest, oxlint replaces ESLint, Rspack replaces Webpack, Standard Schema validation | Plan migration if starting fresh — alpha-4 on npm next tag |
| NestJS 11 (2025) | Current | JSON logging built-in, IntrinsicException, improved startup perf | Requires Node.js 18+ (recommend 22 or 24), 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
- Node.js is single-threaded by default. For multi-core utilization, use
clustermodule, PM2 in cluster mode, or Docker with multiple containers. PHP-FPM handles this transparently. - Express 5.x requires Node.js 18+. It automatically forwards rejected promises to error-handling middleware — no need for the
express-async-errorspackage that was required in Express 4. - PHP's
include/requireexecute at runtime; Node.jsrequire()caches modules after first load. Configuration changes require a server restart. - Session handling differs fundamentally: PHP sessions use server-side files by default; Node.js has no built-in session mechanism. Use
express-sessionwith Redis, or switch to JWT. - PHP's
PDO::ERRMODE_EXCEPTIONthrows on query errors. Knex always returns Promises — ensure every database call has.catch()ortry/catch. - File upload handling differs: PHP populates
$_FILESautomatically. In Node.js, you needmulterorbusboymiddleware — the raw request body is a stream. - Fastify (5.x) is a viable alternative to Express with 2-3x higher throughput in benchmarks. Consider it for new high-traffic services during migration. It offers built-in schema validation, TypeScript support, and a plugin architecture.