Migrating from PHP Framework to React/Next.js
Migrating from Laravel/Symfony/CodeIgniter to Next.js is changing not just technology, but approach to rendering, deployment and responsibility division. Backend stays (or transforms into API), frontend is rewritten.
Migration Strategies
Strangler Fig — gradual replacement: Next.js takes routes one by one until PHP is completely displaced. Nginx/CDN directs traffic to right server by URL patterns.
Big Bang — complete replacement in one deploy. Faster for small sites, riskier for large ones.
Hybrid — Next.js as frontend, PHP as API (Laravel API). Preserves backend investments.
Strangler Fig Approach via Nginx
server {
server_name mysite.com;
# New routes → Next.js
location /blog/ {
proxy_pass http://nextjs:3000;
}
location /about {
proxy_pass http://nextjs:3000;
}
# Old routes → Laravel
location / {
proxy_pass http://laravel:8000;
}
}
Migrating Business Logic
PHP/Laravel validation → Zod:
// Before (Laravel)
// $request->validate([
// 'email' => 'required|email|unique:users',
// 'name' => 'required|min:2|max:255',
// ]);
// After (Zod + React Hook Form)
const schema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2).max(255),
});
PHP ORM Eloquent → Prisma or API layer:
// Before (Eloquent in Laravel)
// User::where('active', true)->with('posts')->paginate(10);
// After (Prisma in Next.js API Route)
const users = await prisma.user.findMany({
where: { active: true },
include: { posts: true },
take: 10,
skip: (page - 1) * 10,
});
Laravel API + Next.js Frontend
Instead of complete backend rewrite — turn Laravel into API:
// Laravel: turn into Sanctum API
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
Route::apiResource('products', ProductController::class);
});
// ProductController.php
public function index(Request $request): JsonResponse
{
$products = Product::query()
->when($request->category, fn($q) => $q->whereHas('category', fn($q) => $q->where('slug', $request->category)))
->paginate($request->per_page ?? 12);
return ProductResource::collection($products)->response();
}
// Next.js: client to Laravel API
import createClient from 'openapi-fetch';
import type { paths } from '@/types/api'; // auto-generated from OpenAPI
const client = createClient<paths>({ baseUrl: process.env.LARAVEL_API_URL });
const { data, error } = await client.GET('/api/products', {
params: { query: { category: 'electronics', per_page: 12 } },
});
Migrating Blade/Twig Templates → React
{{-- Blade --}}
@foreach($products as $product)
<div class="product-card">
<img src="{{ $product->image_url }}" alt="{{ $product->name }}">
<h3>{{ $product->name }}</h3>
<span>{{ number_format($product->price) }} rub.</span>
<a href="{{ route('products.show', $product->slug) }}">More</a>
</div>
@endforeach
// React equivalent
const ProductCard = ({ product }: { product: Product }) => (
<div className="product-card">
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<span>{product.price.toLocaleString('en-US')} rub.</span>
<Link href={`/products/${product.slug}`}>More</Link>
</div>
);
Authentication: PHP Sessions → JWT/Cookies
// Next.js: authentication via Laravel Sanctum
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
export const { auth, handlers } = NextAuth({
providers: [
Credentials({
async authorize(credentials) {
const res = await fetch(`${process.env.LARAVEL_URL}/api/auth/login`, {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) return null;
return res.json();
},
}),
],
});
Migration Timeline
| Site Type | Strangler Fig | Big Bang |
|---|---|---|
| Corporate (10–20 pages) | 4–8 weeks | 3–6 weeks |
| Blog/portal (50–200 pages) | 8–16 weeks | 6–12 weeks |
| E-commerce | 3–6 months | 2–4 months |
| Complex portal | 6–12 months | Not feasible |







