django-admin startproject myapp && python manage.py startapp core (Django) or pip install flask && flask run (Flask) or pip install fastapi uvicorn && uvicorn main:app (FastAPI)managed = False on all Django models generated via inspectdb until you are ready for Django to own the database schema — otherwise makemigrations will attempt to recreate or alter existing PHP tables. [src1]django.db.backends.mysql. [src7]$2y$ prefix; Django defaults to PBKDF2 — add BCryptSHA256PasswordHasher to PASSWORD_HASHERS or all existing users will fail to authenticate. [src4]| 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] |
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]
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.
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.
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.
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"}
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')"
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.
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.
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'),
]
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,
})
# 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
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)
# 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]
# 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]
# 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
# 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
# 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
# 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})
# 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)
# 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)
# 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)
# 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')),
]
# BAD — Unpinned requirements allow breaking changes mid-migration
# requirements.txt
# django
# djangorestframework
# psycopg2
# 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
list for ordered sequences and dict for key-value pairs. Mixing them up causes TypeError exceptions. Fix: audit every $array usage and decide if it maps to list or dict. [src5]inspectdb to generate models from an existing database, Django defaults to managed = True, meaning makemigrations will try to alter existing tables. Fix: set managed = False in the model's Meta class. [src1]$2y$ prefix while Django uses PBKDF2 by default. Fix: add 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher' to PASSWORD_HASHERS and install bcrypt. [src4]null, "", 0, false, and [] are all falsy. Python's None is distinct. Fix: be explicit — use if value is None when checking for null. [src5]{% csrf_token %} in templates. Flask requires flask-wtf. Forgetting this causes 403 errors on all POST requests. Fix: ensure CsrfViewMiddleware is in MIDDLEWARE and templates include the token. [src1, src2]date_default_timezone_set() globally. Django stores everything in UTC by default with USE_TZ = True. Fix: set USE_TZ = True, ensure timezone-aware columns, and audit all date comparisons. [src1]# 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 | Status | Key Features | Migration Notes |
|---|---|---|---|
| Django 5.2 LTS | Current LTS (April 2025) | Composite PKs, auto-import in shell, generated fields | Recommended; LTS through April 2028; requires PostgreSQL 14+ [src7] |
| Django 5.1 | Stable | LoginRequiredMiddleware, db_default | Good choice if 5.2 is too new |
| 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 (Feb 2026) [src2] |
| Flask 2.x | EOL | Async support added | No longer maintained; upgrade to 3.1.x |
| FastAPI 0.115+ | Current | Python 3.12 native, Pydantic v2, async-first | Rapidly growing; best for API-only migrations [src8] |
| PHP 8.3 | Current source | Typed class constants, json_validate() | Map typed properties to Django model fields |
| PHP 8.1 | Stable source | Enums, fibers, readonly | 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 |
| 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 |
TypeError exceptions where PHP silently coerced. Add type hints and run mypy early. [src5]