How to Migrate a PHP Backend to Node.js

Type: Software Reference Confidence: 0.91 Sources: 7 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

PHP PatternNode.js EquivalentExample
$_GET['param'] / $_POST['param']req.query.param / req.body.paramapp.get('/users', (req, res) => { const id = req.query.id; })
PDO::query($sql)Knex query builder or Prisma Clientconst users = await knex('users').where('active', true)
$_SESSION['user']express-session + Redis store or JWTreq.session.user = { id: 1, name: 'Alice' }
middleware() via PHP frameworksapp.use(middleware)app.use(express.json()) or app.use(cors())
move_uploaded_file()multer middlewareupload.single('avatar') then req.file.path
require_once 'template.php'Template engine (EJS/Pug) or API-onlyres.render('index', { title: 'Home' }) or res.json(data)
cron + PHP scriptnode-cron or OS-level croncron.schedule('0 * * * *', () => { runJob() })
try/catch + throwtry/catch + async error middlewareapp.use((err, req, res, next) => { res.status(500).json({error: err.message}) })
password_hash() / password_verify()bcrypt packageconst hash = await bcrypt.hash(password, 12)
getenv('DB_HOST') / .env filesdotenv + process.envrequire('dotenv').config(); const host = process.env.DB_HOST
composer require packagenpm install packagenpm 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

Diagnostic Commands

# 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 History & Compatibility

VersionStatusBreaking ChangesMigration Notes
Node.js 22.x (2024)Current LTS (Jod)require() for ESM (experimental), WebSocket API built-inStable V8 12.x, top-level await, built-in fetch stable, Watch Mode stable
Node.js 20.x (2023)Active LTSPermission model, test runner stableStable 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)CurrentAsync error handling, dropped app.del()Automatic promise rejection forwarding
Express 4.x (2014)MaintenanceUpgrade to 5.x: remove app.del(), update path patterns
NestJS 11 (2025)CurrentJSON logging built-in, IntrinsicException, improved startup perfRequires Node.js 18+ (recommend 20+), Apollo Server v4 for GraphQL
NestJS 10 (2023)MaintenanceSWC compiler defaultRequires Node.js 16+
Prisma 6.x (2025)CurrentQuery Compiler replaces Rust engine, ESM supportLeaner architecture, schema splitting GA, min Node.js 18.18+
Knex 3.x (2023)CurrentESM-first, dropped Node <14Use type: "module" in package.json
PHP 8.4 (2024)Current (source side)Property hooks, new DOM APIJIT improvements, 2x faster than PHP 7.4 on some workloads

When to Use / When Not to Use

Use WhenDon't Use WhenUse 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 wellTeam is highly productive in PHP with no pain pointsStay on PHP 8.4 (JIT, fibers, property hooks)
Building microservices or API-first architectureSimple CMS or blog (WordPress, Drupal)Keep PHP + WordPress, or headless CMS
Need real-time features (WebSocket, SSE)Traditional server-rendered MVC appLaravel/Symfony with Livewire/Hotwire
Unifying frontend and backend in one languagePHP codebase is small (<5K LOC) and stableIncremental PHP modernization (PHP 8.4, PSR-15)
High-concurrency API (>10K concurrent connections)Batch processing or cron jobs onlyKeep PHP scripts, use Node.js only for the API layer

Important Caveats

Related Units