How to Migrate a PHP Application to Python (Django/Flask)

How do I migrate a PHP application to Python (Django/Flask)?

TL;DR

Constraints

Quick Reference

PHP PatternPython/Django EquivalentExample
Route::get('/users', [UserController::class, 'index'])path('users/', views.UserListView.as_view()) in urls.pyDjango class-based view [src4]
Route::get('/users', function() {...})@app.route('/users') decorator (Flask) / @app.get('/users') (FastAPI)Flask/FastAPI route [src2, src8]
$user = User::find($id) (Eloquent)user = User.objects.get(id=id) (Django ORM)Single record fetch [src3, src4]
User::where('active', true)->get()User.objects.filter(active=True)Filtered queryset [src4]
$user->posts()->create([...])Post.objects.create(author=user, ...)Related object creation [src3]
@extends('layouts.app') (Blade){% extends "layouts/base.html" %} (Django/Jinja2)Template inheritance [src4]
{{ $variable }} (Blade){{ variable }} (Django templates / Jinja2)Variable output [src4]
php artisan make:model User -mpython manage.py startapp users + define model in models.pyModel + migration scaffold [src3]
php artisan migratepython manage.py makemigrations && python manage.py migrateRun database migrations [src1, src3]
php artisan tinkerpython manage.py shell (auto-imports models in Django 5.2+)Interactive REPL [src3, src7]
composer require package/namepip install package-name or poetry add package-name or uv add package-namePackage management [src5]
$_SESSION['key'] = 'value'request.session['key'] = 'value' (Django) / session['key'] = 'value' (Flask)Session handling [src4]
Auth::attempt($credentials)authenticate(request, username=..., password=...) (Django)Authentication [src4]
env('DB_HOST') / .envos.environ.get('DB_HOST') / python-dotenv or Django settings.pyEnvironment config [src5]
Middleware class in app/Http/Middleware/MIDDLEWARE list in settings.py (Django) / @app.before_request (Flask) / middleware stack (FastAPI)Middleware [src4, src8]

Decision Tree

Full script: decision-tree.txt (38 lines)

START: Should I migrate my PHP app to Python?
+-- Is your PHP app actively maintained and working well?
|   +-- YES -> Consider staying with PHP. Migration has real cost. Only proceed if
|   |          there's a strategic reason (team skills, ecosystem needs, ML/AI integration).
|   +-- NO v
+-- Do you need ML/AI, data science, or scientific computing?
|   +-- YES -> Migrate to Python. Python's ecosystem is unmatched here. [src8]
|   +-- NO v
+-- Is the codebase > 100K lines of PHP?
|   +-- YES -> Use Strangler Fig pattern. Run both side-by-side. -> See Step 4 [src6]
|   +-- NO v
+-- Do you need a full-featured admin, ORM, auth out of the box?
|   +-- YES -> Choose Django -> See Code Example 1 [src4]
|   +-- NO v
+-- Need a lightweight microservice or API?
|   +-- YES -> Choose Flask or FastAPI -> See Code Example 2 [src2, src8]
|   +-- NO v
+-- DEFAULT -> Django (batteries-included is safer for most migrations) [src3]

Framework choice:
+-- Need admin panel, user management, ORM, forms? -> Django [src4]
+-- Need maximum flexibility, minimal overhead? -> Flask [src2]
+-- Need async-first, high-performance API, auto-docs? -> FastAPI [src8]
+-- Migrating from Laravel specifically? -> Django (closest conceptual match) [src3]

Decision Logic

Agent-ready if/then rules for picking the migration target and strategy. Each rule maps a concrete situation to a recommendation.

If the codebase is a production app over 100K lines

→ Use the strangler fig pattern: run PHP and Python behind a reverse proxy and convert one route/module at a time; never attempt a big-bang rewrite (60-70% fail). [src6]

If you need a batteries-included framework (admin, ORM, auth, forms)

→ Choose Django, and target Django 5.2 LTS (supported through April 2028) rather than 6.0 unless you fully control the Python 3.12+ runtime. [src4, src7, src9]

If you need an async-first, API-only service with auto-generated OpenAPI docs

→ Choose FastAPI (0.115+, latest 0.136.x); it outperforms Flask/Django for I/O-bound APIs but ships no admin/ORM/templates. [src8]

If you need a minimal, flexible microservice without Django's conventions

→ Choose Flask 3.1.x with Flask-SQLAlchemy and Flask-Migrate; add flask-wtf for CSRF. [src2]

If your PHP app stores Laravel bcrypt ($2y$) password hashes

→ Add BCryptSHA256PasswordHasher to PASSWORD_HASHERS and install bcrypt before pointing Python at the users table, or every login fails. [src4]

If the PHP app runs MySQL and you want Django on PostgreSQL

→ Keep Django on MySQL via django.db.backends.mysql during transition, or run a dedicated data-migration step first; do not switch the database engine and the framework in the same cutover. [src1, src7]

If the PHP app is small, stable, well-maintained, and has no ML/AI need

→ Do not migrate; the maintenance cost is lower than the migration cost. Revisit only if team skills or ecosystem needs change. [src5, src6]

Step-by-Step Guide

1. Audit the PHP codebase and map dependencies

Catalog every route, model, middleware, job, and third-party integration in your PHP app. Create a spreadsheet mapping each PHP component to its Python equivalent. Identify shared infrastructure (databases, caches, queues) that both systems must access during the transition. [src6]

# List all routes in Laravel
php artisan route:list --json > routes_inventory.json

# Count models, controllers, middleware
find app/Models -name "*.php" | wc -l
find app/Http/Controllers -name "*.php" | wc -l
find app/Http/Middleware -name "*.php" | wc -l

# List Composer dependencies and check for Python equivalents
composer show --format=json > php_dependencies.json

# Estimate lines of code
find app/ -name "*.php" -exec cat {} + | wc -l

Verify: Review routes_inventory.json — every route must have a planned Python equivalent before proceeding.

2. Set up the Python project alongside PHP

Create the Django or Flask project in a separate directory. Both apps will run simultaneously, sharing the same database. Use a reverse proxy (Nginx, Caddy, or Cloudflare) to route traffic to the appropriate backend. [src4]

# Django setup (recommended for most migrations)
python -m venv venv
source venv/bin/activate  # Linux/macOS
# venv\Scripts\activate   # Windows
pip install django==5.2.* djangorestframework psycopg[binary] python-dotenv
django-admin startproject myproject .
python manage.py startapp core

# Flask setup (alternative — lightweight)
pip install flask flask-sqlalchemy flask-migrate python-dotenv
mkdir -p app/{models,routes,templates}

# FastAPI setup (alternative — async-first APIs)
pip install fastapi uvicorn[standard] sqlalchemy python-dotenv
mkdir -p app/{models,routes}
# Django: myproject/settings.py — connect to existing PHP database
import os
from dotenv import load_dotenv
load_dotenv()

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

Verify: python manage.py checkSystem check identified no issues.

3. Introspect and generate models from the existing database

Django can reverse-engineer your existing PHP database schema into Python models. This avoids rewriting schema definitions manually and ensures your Python app reads the same tables. [src1]

# Generate models from existing database
python manage.py inspectdb > core/models_generated.py

# Review and clean up the generated models, then move to models.py
# Key: set managed = False initially to prevent Django from altering tables
# core/models.py — cleaned up from inspectdb output
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=255)
    email = models.EmailField(unique=True)
    password = models.CharField(max_length=255)  # Laravel bcrypt hash
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'users'      # Match existing PHP table name
        managed = False          # Don't let Django alter this table yet

Verify: python manage.py shell -c "from core.models import User; print(User.objects.count())" — should return the same count as your PHP database.

4. Migrate routes incrementally using the strangler fig pattern

Start with low-traffic, read-only API endpoints. Configure your reverse proxy to route specific paths to the Python backend while everything else continues going to PHP. The rhythm is: proxy, extract, validate, repeat. [src6]

# Nginx config: route /api/v2/* to Django, everything else to PHP
upstream php_backend {
    server 127.0.0.1:8080;
}
upstream django_backend {
    server 127.0.0.1:8000;
}

server {
    listen 80;

    # New Python endpoints
    location /api/v2/ {
        proxy_pass http://django_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Everything else stays on PHP
    location / {
        proxy_pass http://php_backend;
        proxy_set_header Host $host;
    }
}

Verify: curl -s http://localhost/api/v2/health | jq .{"status": "ok", "backend": "django"}

5. Convert authentication and session handling

Authentication is the critical bridge between PHP and Python. Both systems must validate the same user sessions during the transition period. The simplest approach is token-based auth (JWT) shared between both backends. [src4]

# Django: shared JWT authentication between PHP and Python
# pip install djangorestframework-simplejwt
from datetime import timedelta

# myproject/settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}

SIMPLE_JWT = {
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': os.environ.get('JWT_SECRET'),  # Same secret as PHP
    'AUTH_HEADER_TYPES': ('Bearer',),
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
}

# Handle Laravel's bcrypt $2y$ password hashes
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]
// PHP side: generate JWT tokens compatible with Django
// composer require firebase/php-jwt
use Firebase\JWT\JWT;

$payload = [
    'user_id' => $user->id,
    'exp' => time() + 3600,
    'iat' => time(),
];
$token = JWT::encode($payload, env('JWT_SECRET'), 'HS256');

Verify: Generate a token from PHP, validate it in Django: python manage.py shell -c "from rest_framework_simplejwt.tokens import UntypedToken; UntypedToken('TOKEN_HERE')"

6. Migrate templates and frontend layer

Convert Blade/Twig templates to Django templates or Jinja2. If you are building an API-first architecture, this step may be replaced by serving a JavaScript frontend (React, Vue) that calls your new Python API. [src3, src4]

<!-- PHP Blade template -->
@extends('layouts.app')
@section('content')
    <h1>{{ $user->name }}</h1>
    @foreach($posts as $post)
        <article>{{ $post->title }}</article>
    @endforeach
@endsection

<!-- Django template equivalent -->
{% extends "layouts/base.html" %}
{% block content %}
    <h1>{{ user.name }}</h1>
    {% for post in posts %}
        <article>{{ post.title }}</article>
    {% endfor %}
{% endblock %}

Verify: Compare HTML output of both templates — diff <(curl -s php-app/users/1) <(curl -s django-app/users/1) should show minimal structural differences.

7. Decommission PHP routes and clean up

Once all routes are migrated and tested, remove the reverse proxy split, point all traffic to Python, and retire the PHP codebase. Run a parallel period of at least 2-4 weeks with monitoring before full cutover. [src6]

# Run Django test suite
python manage.py test --verbosity=2

# Check for any remaining PHP route references
grep -r "php_backend" /etc/nginx/conf.d/

# Monitor error rates after cutover
# Set up alerting on 5xx responses for the first 2 weeks

# Verify all traffic goes through Python
python manage.py shell -c "from django.contrib.auth.models import User; print(f'Total users: {User.objects.count()}')"

Verify: php artisan route:list should be empty or the PHP app should be fully stopped. All traffic goes through Python.

Code Examples

Django: REST API with Model and Serializer

Full script: django-rest-api-with-model-and-serializer.py (54 lines)

# Input:  HTTP GET /api/v2/products/?category=electronics&limit=10
# Output: JSON array of product objects with pagination
# core/models.py
from django.db import models
class Product(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    category = models.CharField(max_length=100, db_index=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    is_active = models.BooleanField(default=True)
    class Meta:
        db_table = 'products'   # Match existing PHP table
        ordering = ['-created_at']
# ... (see full script)

Flask: Equivalent REST API

Full script: flask-equivalent-rest-api.py (50 lines)

# Input:  HTTP GET /api/v2/products/?category=electronics&limit=10
# Output: JSON array of product objects with pagination
# app.py
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
import os

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
db = SQLAlchemy(app)
# ... (see full script)

FastAPI: High-Performance Async API

# Input:  HTTP GET /api/v2/products/?category=electronics&limit=10
# Output: JSON array of product objects with auto-generated OpenAPI docs
# main.py
from fastapi import FastAPI, Query
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from pydantic import BaseModel
from typing import Optional
import os

app = FastAPI(title="Migrated API", version="2.0")

class ProductResponse(BaseModel):
    id: int
    name: str
    slug: str
    category: str
    price: float
    is_active: bool

    class Config:
        from_attributes = True  # Replaces orm_mode in Pydantic v2

@app.get("/api/v2/products/", response_model=list[ProductResponse])
async def list_products(
    category: Optional[str] = None,
    limit: int = Query(default=20, le=100),
    page: int = Query(default=1, ge=1),
):
    """List products — auto-documented at /docs."""
    # SQLAlchemy async query against existing PHP database
    query = select(Product).where(Product.is_active == True)
    if category:
        query = query.where(Product.category == category)
    query = query.offset((page - 1) * limit).limit(limit)
    async with AsyncSession(engine) as session:
        result = await session.execute(query)
        return result.scalars().all()

Database Migration Script: Verify Data Integrity

Full script: database-migration-script-verify-data-integrity.py (33 lines)

# Input:  Existing PHP/MySQL database and new Django/PostgreSQL setup
# Output: Comparison report of record counts and data integrity
# scripts/verify_migration.py
"""
Cross-database verification script.
# ... (see full script)

Anti-Patterns

Wrong: Big-bang rewrite (rewriting everything at once)

# BAD — Attempting to rewrite the entire PHP app from scratch
# This is the "Second System Effect" — takes months/years, often fails

# "Let's just rewrite the whole thing in Django over the next 6 months"
# Meanwhile: PHP app gets zero maintenance, bugs pile up,
# business features are frozen, team morale drops

# Result: 60-70% of big-bang rewrites are abandoned or significantly
# delayed (industry experience) [src6]

Correct: Strangler fig pattern (incremental migration)

# GOOD — Migrate one module at a time, both systems run in parallel
# Start with the lowest-risk, highest-value endpoint

# Week 1-2: Migrate /api/v2/health and /api/v2/products (read-only)
# Week 3-4: Migrate /api/v2/users (read-only)
# Week 5-8: Migrate write operations for products
# Week 9-12: Migrate authentication
# ...continue until PHP serves zero routes [src6]

Wrong: Direct SQL queries instead of ORM

# BAD — Bringing PHP habits into Python
from django.db import connection

def get_active_users():
    with connection.cursor() as cursor:
        cursor.execute("SELECT * FROM users WHERE active = 1")
        return cursor.fetchall()  # Returns raw tuples, no model instances

Correct: Use the Django ORM properly

# GOOD — Use Django ORM querysets for type safety, SQL injection prevention,
# and lazy evaluation [src1]
from core.models import User

def get_active_users():
    return User.objects.filter(active=True).select_related('profile')
    # Returns model instances, supports chaining, auto-escapes values

Wrong: Copying PHP's $GLOBALS / global state pattern

# BAD — Using global variables like PHP superglobals
_current_user = None  # Module-level mutable state

def login(user_id):
    global _current_user
    _current_user = User.objects.get(id=user_id)

def get_dashboard():
    # Relies on global state — breaks in multi-threaded/async contexts
    return render(f"Welcome {_current_user.name}")

Correct: Use request context and dependency injection

# GOOD — Django/Flask handle request state through the request object [src2, src4]
# Django
from django.contrib.auth.decorators import login_required

@login_required
def get_dashboard(request):
    # request.user is thread-safe, scoped to this request
    return render(request, 'dashboard.html', {'user': request.user})

# Flask
from flask_login import login_required, current_user

@app.route('/dashboard')
@login_required
def get_dashboard():
    return render_template('dashboard.html', user=current_user)

# FastAPI
from fastapi import Depends
from .auth import get_current_user

@app.get('/dashboard')
async def get_dashboard(user: User = Depends(get_current_user)):
    return {"message": f"Welcome {user.name}"}

Wrong: Mixing PHP naming conventions in Python code

# BAD — Using camelCase and PHP naming patterns in Python
class UserController:  # Python uses "View" not "Controller" in Django
    def getUserById(self, userId):  # camelCase is not Pythonic
        $user = User.objects.get(id=userId)  # $ prefix doesn't exist in Python
        return $user

Correct: Follow Python/Django conventions (PEP 8)

# GOOD — snake_case, Django naming conventions [src3, src5]
class UserDetailView(generics.RetrieveAPIView):  # "View" in Django
    def get_queryset(self):  # snake_case methods
        user_id = self.kwargs['pk']  # No $ prefix
        return User.objects.filter(id=user_id)

Wrong: Ignoring Python's package structure

# BAD — Flat file structure like a PHP project
# myproject/
#   models.py          (all models in one file)
#   views.py           (all views in one file)
#   urls.py            (500+ routes in one file)

Correct: Use Django apps for modular organization

# GOOD — Django app-per-domain pattern [src1, src4]
# myproject/
#   users/
#     models.py, views.py, urls.py, serializers.py, tests.py
#   products/
#     models.py, views.py, urls.py, serializers.py, tests.py
#   orders/
#     models.py, views.py, urls.py, serializers.py, tests.py

# myproject/urls.py
from django.urls import path, include

urlpatterns = [
    path('api/v2/users/', include('users.urls')),
    path('api/v2/products/', include('products.urls')),
    path('api/v2/orders/', include('orders.urls')),
]

Wrong: Not pinning dependencies during migration

# BAD — Unpinned requirements allow breaking changes mid-migration
# requirements.txt
django
djangorestframework
psycopg2

Correct: Pin all dependency versions

# GOOD — Pinned requirements ensure reproducible builds [src5]
# requirements.txt
django==5.2.1
djangorestframework==3.15.2
psycopg[binary]==3.2.4
python-dotenv==1.0.1

# Or better: use pyproject.toml with uv/poetry for lockfile support

Common Pitfalls

Diagnostic Commands

# Check Django project configuration for production readiness
python manage.py check --deploy

# Verify database connectivity
python manage.py dbshell

# Introspect existing PHP database schema into Django models
python manage.py inspectdb > models_generated.py

# Compare model definitions vs actual database
python manage.py showmigrations

# Run Django development server
python manage.py runserver 0.0.0.0:8000

# List all registered URL routes in Django (requires django-extensions)
python manage.py show_urls

# Check Flask routes
flask routes

# Verify Python/pip environment
python --version && pip list --format=columns

# Test database connection from Python
python -c "import psycopg; conn = psycopg.connect('dbname=mydb user=myuser'); print('Connected'); conn.close()"

# Compare record counts between PHP and Python databases
php artisan tinker --execute="echo App\Models\User::count();"
python manage.py shell -c "from core.models import User; print(User.objects.count())"

# Verify bcrypt password compatibility
python -c "import bcrypt; print(bcrypt.checkpw(b'testpassword', b'\$2y\$10\$HASH_FROM_PHP'))"

# Check Django 5.2 compatibility with current database
python manage.py migrate --check

Version History & Compatibility

VersionStatusKey FeaturesMigration Notes
Django 6.0Current (Dec 2025)Built-in CSP, template partials, background tasks framework, AsyncPaginator, cross-DB StringAggRequires Python 3.12-3.14; dropped Python 3.10/3.11; removed cx_Oracle; custom expression params must be tuples; not yet an LTS [src9]
Django 5.2 LTSCurrent LTS (April 2025)Composite PKs, generated fields, auto-import in shell, facet filtersRecommended migration target; LTS support through April 2028; supports Python 3.10-3.13; requires PostgreSQL 14+ [src7]
Django 5.1EOL Dec 2025LoginRequiredMiddleware, db_defaultEnd-of-life; jump to 5.2 LTS or 6.0
Django 4.2 LTSEOL April 2026Psycopg 3 support, comments on columnsEnd-of-life approaching; migrate to 5.2 LTS [src7]
Flask 3.1.xCurrent (Nov 2024)Partitioned cookies, per-request max_content_length, Python 3.9+ requiredStable; latest patch 3.1.3 [src2]
Flask 2.xEOLAsync support added, nested blueprintsNo longer maintained; upgrade to 3.1.x
FastAPI 0.115+Current (latest 0.136.x, Apr 2026)Python 3.12 baseline, Pydantic v2, async-firstRapidly growing; best for API-only migrations [src8]
PHP 8.5Current source (Nov 2025)Pipe operator |>, clone-with, array_first()/array_last(), URI/Lexbor libsNewest migration source; map pipe chains and property hooks to Python expressions
PHP 8.4Stable source (Nov 2024)Property hooks, asymmetric visibility, new HTML5 DOM API, array_find()Map property hooks to Python @property; asymmetric visibility has no direct Python equivalent
PHP 8.3Maintenance sourceTyped class constants, json_validate(), readonly amendmentsMap typed properties to Django model fields
PHP 8.1EOL (security-only ended)Enums, fibers, readonly propertiesEOL — map PHP enums to Django TextChoices/IntegerChoices
Laravel 11.xCurrent sourceSimplified structure, per-second schedulingMap artisan commands to manage.py equivalents [src3]
Laravel 10.xLTS sourceProcess interaction, pest testingMost common migration source

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Team expertise is shifting to PythonPHP app is small, stable, and well-maintainedKeep PHP; maintenance < migration cost
You need ML/AI/data science integration (PyTorch, scikit-learn, pandas)You only need a simple CMS or blogWordPress or Laravel with PHP — mature ecosystems
PHP codebase is unmaintainable (no tests, no docs, no team knowledge)Timeline is less than 3 months for a large appIncremental refactoring within PHP first
You want Django's built-in admin, ORM, auth, or FastAPI's auto-docsYour team has zero Python experienceInvest in PHP modernization (Laravel upgrade) or train team first
Building microservices / API-first architectureLegacy PHP app has heavy WordPress/Drupal plugin dependenciesStay on PHP; plugin ecosystem has no Python equivalent
Need async capabilities (Django 5.x async views, FastAPI, ASGI)Database schema is tightly coupled to PHP-specific tooling (MySQL stored procs)Decouple database layer first, then consider migration
Multiple services need to share ML models or data pipelinesPHP app uses extensive Composer packages with no Python equivalentsEvaluate bridge solutions (API gateway) or stay on PHP

Important Caveats