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

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

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') (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 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

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.
|   +-- NO v
+-- Do you need ML/AI, data science, or scientific computing?
|   +-- YES -> Migrate to Python. Python's ecosystem is unmatched. [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 match) [src3]

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
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
pip install django==5.2.* djangorestframework psycopg[binary] python-dotenv
django-admin startproject myproject .
python manage.py startapp core
# 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 bcrypt
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 the rendered output to identify 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. No PHP processes running.

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)
    description = models.TextField(blank=True)
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'products'   # Match existing PHP table
        ordering = ['-created_at']

    def __str__(self):
        return self.name


# core/serializers.py
from rest_framework import serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'slug', 'category', 'price',
                  'description', 'is_active', 'created_at']
        read_only_fields = ['id', 'created_at']


# core/views.py
from rest_framework import generics, filters
from rest_framework.pagination import PageNumberPagination
from .models import Product
from .serializers import ProductSerializer

class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = 'limit'
    max_page_size = 100

class ProductListView(generics.ListCreateAPIView):
    serializer_class = ProductSerializer
    pagination_class = StandardPagination
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['name', 'description']
    ordering_fields = ['price', 'created_at', 'name']

    def get_queryset(self):
        qs = Product.objects.filter(is_active=True)
        category = self.request.query_params.get('category')
        if category:
            qs = qs.filter(category=category)
        return qs


# myproject/urls.py
from django.urls import path
from core.views import ProductListView

urlpatterns = [
    path('api/v2/products/', ProductListView.as_view(), name='product-list'),
]

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

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')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)


class Product(db.Model):
    __tablename__ = 'products'  # Match existing PHP table

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255), nullable=False)
    slug = db.Column(db.String(255), unique=True, nullable=False)
    category = db.Column(db.String(100), index=True)
    price = db.Column(db.Numeric(10, 2), nullable=False)
    description = db.Column(db.Text, default='')
    is_active = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, server_default=db.func.now())
    updated_at = db.Column(db.DateTime, onupdate=db.func.now())

    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'slug': self.slug,
            'category': self.category,
            'price': float(self.price),
            'description': self.description,
            'is_active': self.is_active,
            'created_at': self.created_at.isoformat() if self.created_at else None,
        }


@app.route('/api/v2/products/', methods=['GET'])
def list_products():
    category = request.args.get('category')
    limit = min(int(request.args.get('limit', 20)), 100)
    page = int(request.args.get('page', 1))

    query = Product.query.filter_by(is_active=True)
    if category:
        query = query.filter_by(category=category)

    query = query.order_by(Product.created_at.desc())
    pagination = query.paginate(page=page, per_page=limit, error_out=False)

    return jsonify({
        'results': [p.to_dict() for p in pagination.items],
        'total': pagination.total,
        'page': page,
        'pages': pagination.pages,
    })

FastAPI: High-Performance Async API

# Input:  HTTP GET /api/v2/products/?category=electronics&limit=10
# Output: JSON array with auto-generated OpenAPI docs at /docs

from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import Optional

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  # 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
    pass  # Implementation uses AsyncSession

Database Migration: 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
import os, sys, django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()

from django.db import connections
from core.models import Product, User

def verify_table(model, table_name):
    """Compare record counts between PHP (legacy) and Django databases."""
    django_count = model.objects.count()

    with connections['legacy'].cursor() as cursor:
        cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
        legacy_count = cursor.fetchone()[0]

    status = "OK" if django_count == legacy_count else "MISMATCH"
    print(f"  {table_name}: legacy={legacy_count}, django={django_count} [{status}]")
    return django_count == legacy_count

if __name__ == '__main__':
    print("Migration verification report")
    print("=" * 50)
    tables = [(Product, 'products'), (User, 'users')]
    all_ok = all(verify_table(model, table) for model, table in tables)
    sys.exit(0 if all_ok else 1)

Anti-Patterns

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

# BAD — Attempting to rewrite the entire PHP app from scratch
# "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 delayed [src6]

Correct: Strangler fig pattern (incremental migration)

# GOOD — Migrate one module at a time, both systems run in parallel
# 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 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():
    return render(f"Welcome {_current_user.name}")  # Breaks in multi-threaded contexts

Correct: Use request context and dependency injection

# GOOD — Django/Flask handle request state through the request object [src2, src4]
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})

Wrong: Mixing PHP naming conventions in Python code

# BAD — Using camelCase and PHP naming patterns
class UserController:  # Should be "View" in Django
    def getUserById(self, userId):  # camelCase is not Pythonic
        return User.objects.get(id=userId)

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

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 (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
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
python manage.py migrate --check

Version History & Compatibility

VersionStatusKey FeaturesMigration Notes
Django 5.2 LTSCurrent LTS (April 2025)Composite PKs, auto-import in shell, generated fieldsRecommended; LTS through April 2028; requires PostgreSQL 14+ [src7]
Django 5.1StableLoginRequiredMiddleware, db_defaultGood choice if 5.2 is too new
Django 4.2 LTSEOL April 2026Psycopg 3, column commentsEnd-of-life approaching; migrate to 5.2 LTS [src7]
Flask 3.1.xCurrent (Nov 2024)Partitioned cookies, per-request max_content_lengthRequires Python 3.9+; latest patch 3.1.3 (Feb 2026) [src2]
Flask 2.xEOLAsync support addedNo longer maintained; upgrade to 3.1.x
FastAPI 0.115+CurrentPython 3.12 native, Pydantic v2, async-firstRapidly growing; best for API-only migrations [src8]
PHP 8.3Current sourceTyped class constants, json_validate()Map typed properties to Django model fields
PHP 8.1Stable sourceEnums, fibers, readonlyMap PHP enums to Django TextChoices/IntegerChoices
Laravel 11.xCurrent sourceSimplified structureMap artisan commands to manage.py equivalents [src3]
Laravel 10.xLTS sourceProcess interactionMost 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
PHP codebase is unmaintainable (no tests, no docs)Timeline is less than 3 months for a large appIncremental refactoring within PHP first
You want Django's admin, ORM, auth, or FastAPI's auto-docsYour team has zero Python experienceInvest in PHP modernization or train team first
Building microservices / API-first architectureLegacy PHP has heavy WordPress/Drupal plugin dependenciesStay on PHP; plugin ecosystem has no Python equivalent
Need async capabilities (Django 5.x, FastAPI, ASGI)Database schema tightly coupled to PHP-specific toolingDecouple 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

Related Units