How to Migrate a PHP Application to Python (Django/Flask)
How do I migrate a PHP application to Python (Django/Flask)?
TL;DR
- Bottom line: Migrate incrementally using the strangler fig pattern — run PHP and Python side-by-side behind a reverse proxy, converting one route/module at a time, starting with the API layer and working inward to models and templates. [src6]
- Key tool/command:
django-admin startproject myapp && python manage.py startapp core(Django) orpip install flask && flask run(Flask) orpip install fastapi uvicorn && uvicorn main:app(FastAPI) - Watch out for: Trying to do a big-bang rewrite — the #1 cause of failed migrations. Always migrate incrementally. [src6]
- Works with: Django 5.2 LTS (Python 3.10-3.13, recommended for migrations) or Django 6.0 (Python 3.12-3.14), Flask 3.1.x, FastAPI 0.115+ (latest 0.136.x), PHP 8.1-8.5 as source [src9]
Constraints
- Never attempt a big-bang rewrite of a production PHP app — use the strangler fig pattern to migrate incrementally, or risk project failure (60-70% of full rewrites are abandoned or severely delayed). [src6]
- Set
managed = Falseon all Django models generated viainspectdbuntil you are ready for Django to own the database schema — otherwisemakemigrationswill attempt to recreate or alter existing PHP tables. [src1] - Both PHP and Python backends must share the same JWT secret during the transition period — mismatched secrets cause silent authentication failures across the proxy boundary. [src4]
- Django 5.2 LTS requires PostgreSQL 14+; if your PHP app runs MySQL, plan a database migration step or use
django.db.backends.mysql. [src7] - Django 6.0 (Dec 2025) dropped Python 3.10/3.11 (requires 3.12+), removed
cx_Oracle, and made custom ORM expressionas_sql()params return tuples (not lists). For a migration target, prefer the 5.2 LTS line (supported through April 2028) unless you control the Python runtime. [src9] - Laravel bcrypt hashes use
$2y$prefix; Django defaults to PBKDF2 — addBCryptSHA256PasswordHashertoPASSWORD_HASHERSor all existing users will fail to authenticate. [src4]
Quick Reference
| PHP Pattern | Python/Django Equivalent | Example |
|---|---|---|
Route::get('/users', [UserController::class, 'index']) | path('users/', views.UserListView.as_view()) in urls.py | Django 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 -m | python manage.py startapp users + define model in models.py | Model + migration scaffold [src3] |
php artisan migrate | python manage.py makemigrations && python manage.py migrate | Run database migrations [src1, src3] |
php artisan tinker | python manage.py shell (auto-imports models in 5.2+) | Interactive REPL [src3, src7] |
composer require package/name | pip install package-name or poetry add package-name or uv add package-name | Package 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') / .env | os.environ.get('DB_HOST') / python-dotenv or Django settings.py | Environment 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]
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
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 check → System 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
- PHP arrays are not Python lists: PHP arrays serve as both indexed arrays and associative arrays (dicts). In Python, use
listfor ordered sequences anddictfor key-value pairs. Mixing them up causesTypeErrorexceptions. Fix: audit every$arrayusage and decide if it maps tolistordict. [src5] - Forgetting managed = False on introspected models: When using
inspectdbto generate models from an existing database, Django defaults tomanaged = True, meaningmakemigrationswill try to alter existing tables. Fix: setmanaged = Falsein the model'sMetaclass. [src1] - Password hash incompatibility: Laravel uses bcrypt with a
$2y$prefix while Django uses PBKDF2 by default. Fix: add'django.contrib.auth.hashers.BCryptSHA256PasswordHasher'toPASSWORD_HASHERSand installbcrypt. [src4] - Session format mismatch during transition: PHP serializes sessions differently from Django. Users will be logged out when traffic switches between backends. Fix: use token-based auth (JWT) shared between both backends during the transition. [src4, src6]
- NULL vs None semantics: PHP's
null,"",0,false, and[]are all falsy. Python'sNoneis distinct. Fix: be explicit — useif value is Nonewhen checking for null. [src5] - Missing CSRF protection: Django requires
{% csrf_token %}in templates. Flask requiresflask-wtf. Forgetting this causes 403 errors on all POST requests. Fix: ensureCsrfViewMiddlewareis inMIDDLEWAREand templates include the token. [src1, src2] - Timezone handling differences: PHP uses
date_default_timezone_set()globally. Django stores everything in UTC by default withUSE_TZ = True. Fix: setUSE_TZ = True, ensure timezone-aware columns, and audit all date comparisons. [src1] - Deployment model mismatch: PHP runs on cheap shared hosting with Apache/mod_php. Python requires WSGI/ASGI servers (Gunicorn, Uvicorn) and typically container-based deployment. Fix: budget for infrastructure changes and consider containerization (Docker) early. [src8]
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
| Version | Status | Key Features | Migration Notes |
|---|---|---|---|
| Django 6.0 | Current (Dec 2025) | Built-in CSP, template partials, background tasks, AsyncPaginator, cross-DB StringAgg | Requires 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 LTS | Current LTS (April 2025) | Composite PKs, auto-import in shell, generated fields | Recommended migration target; LTS through April 2028; supports Python 3.10-3.13; requires PostgreSQL 14+ [src7] |
| Django 5.1 | EOL Dec 2025 | LoginRequiredMiddleware, db_default | End-of-life; jump to 5.2 LTS or 6.0 |
| Django 4.2 LTS | EOL April 2026 | Psycopg 3, column comments | End-of-life approaching; migrate to 5.2 LTS [src7] |
| Flask 3.1.x | Current (Nov 2024) | Partitioned cookies, per-request max_content_length | Requires Python 3.9+; latest patch 3.1.3 [src2] |
| Flask 2.x | EOL | Async support added | No longer maintained; upgrade to 3.1.x |
| FastAPI 0.115+ | Current (latest 0.136.x, Apr 2026) | Python 3.12 baseline, Pydantic v2, async-first | Rapidly growing; best for API-only migrations [src8] |
| PHP 8.5 | Current source (Nov 2025) | Pipe operator, clone-with, array_first()/array_last() | Newest migration source; map pipe chains and property hooks to Python expressions |
| PHP 8.4 | Stable source (Nov 2024) | Property hooks, asymmetric visibility, HTML5 DOM API, array_find() | Map property hooks to Python @property; asymmetric visibility has no direct Python equivalent |
| PHP 8.3 | Maintenance source | Typed class constants, json_validate() | Map typed properties to Django model fields |
| PHP 8.1 | EOL (security-only ended) | Enums, fibers, readonly | EOL — map PHP enums to Django TextChoices/IntegerChoices |
| Laravel 11.x | Current source | Simplified structure | Map artisan commands to manage.py equivalents [src3] |
| Laravel 10.x | LTS source | Process interaction | Most common migration source |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Team expertise is shifting to Python | PHP app is small, stable, and well-maintained | Keep PHP; maintenance < migration cost |
| You need ML/AI/data science integration (PyTorch, scikit-learn, pandas) | You only need a simple CMS or blog | WordPress or Laravel with PHP |
| PHP codebase is unmaintainable (no tests, no docs) | Timeline is less than 3 months for a large app | Incremental refactoring within PHP first |
| You want Django's admin, ORM, auth, or FastAPI's auto-docs | Your team has zero Python experience | Invest in PHP modernization or train team first |
| Building microservices / API-first architecture | Legacy PHP has heavy WordPress/Drupal plugin dependencies | Stay on PHP; plugin ecosystem has no Python equivalent |
| Need async capabilities (Django 5.x, FastAPI, ASGI) | Database schema tightly coupled to PHP-specific tooling | Decouple database layer first, then consider migration |
| Multiple services need to share ML models or data pipelines | PHP app uses extensive Composer packages with no Python equivalents | Evaluate bridge solutions (API gateway) or stay on PHP |
Important Caveats
- Django and Laravel are conceptually similar (both batteries-included MVC frameworks), but the terminology is inverted: Laravel's "Controller" is Django's "View", and Laravel's "View" (Blade) is Django's "Template". This causes significant confusion during migration. [src3]
- Automated code translation tools (like Ispirer or AI-based converters) can handle syntax conversion but cannot replicate framework-specific patterns, middleware chains, or business logic. Budget 30-40% of migration time for manual review and refactoring.
- PHP's loose typing means many implicit type coercions happen silently. Python is strongly typed at runtime — migrated code will throw
TypeErrorexceptions where PHP silently coerced. Add type hints and runmypyearly. [src5] - Database migration is often the hardest part. If your PHP app uses MySQL-specific features (e.g., ENUM columns, spatial queries), verify Django/PostgreSQL equivalents exist before committing. [src1]
- Hosting costs and deployment patterns change significantly. PHP runs on cheap shared hosting with Apache/mod_php. Python requires WSGI/ASGI servers (Gunicorn, Uvicorn) and typically container-based deployment.
- FastAPI is gaining significant traction (20% adoption in 2025 surveys) and is the strongest option for async API-only migrations. However, it lacks Django's built-in admin, ORM, and template system. [src8]
- Django 5.2 LTS composite primary key support cannot be retrofitted to existing models with single PKs — plan table structures before migration if you need composite keys. [src7]
- Django 6.0 (Dec 2025) is the newest line but is not an LTS. It drops Python 3.10/3.11, removes
cx_Oracle, requires custom ORM expressionas_sql()methods to return params as tuples (not lists), and makesDEFAULT_AUTO_FIELDdefault toBigAutoField. For a migration where you want a long, stable target, pick Django 5.2 LTS; choose 6.0 only if you want its built-in CSP, template partials, or new background-tasks framework and control the Python 3.12+ runtime. [src9]