How to Migrate a PHP Backend to Go
How do I migrate a PHP backend to Go?
TL;DR
- Bottom line: Use the strangler fig pattern — run Go and PHP side by side behind a reverse proxy, migrate one endpoint at a time, and let PHP shrink until it is gone. Muzz cut P50 latency by 57% doing exactly this at 2,000 req/s. [src2, src4]
- Key tool/command:
go mod init yourproject && go get github.com/gin-gonic/gin - Watch out for: Attempting a big-bang rewrite — most PHP-to-Go projects that fail do so because teams tried to rewrite everything at once instead of migrating incrementally. [src2, src3]
- Works with: Go 1.22+ (1.26 current as of Feb 2026), any PHP version (5.6–8.4), works with NGINX/Caddy/Traefik reverse proxy, Docker, Kubernetes.
Constraints
- Go's concurrency model (goroutines + channels) has no direct PHP equivalent — you must redesign concurrent workflows, not translate them line-by-line. [src2]
- Go's
html/templateis intentionally minimal — there is no Blade/Twig equivalent. Server-rendered UIs require a fundamentally different approach or a separate frontend (React, Vue, htmx). [src8] - Go has no generics-based ORM as mature as Eloquent — use
sqlxfor direct SQL,GORMfor rapid development, orsqlcfor type-safe code generation from SQL. [src6] - PHP's exception-based error handling does not map to Go — adopt explicit
if err != nilreturns throughout. Usingpanic/recoveras a substitute is an anti-pattern. [src5, src8] - Go binaries are statically linked single files — there is no
composer autoloador dynamic class loading. All dependencies are compiled in at build time. - Never open a new database connection per request (the PHP-FPM habit) — Go uses a long-lived connection pool shared across all goroutines. [src6]
Quick Reference
| PHP Pattern | Go Equivalent | Example |
|---|---|---|
$router->get('/users', fn) (Laravel) | r.GET("/users", handler) (Gin) | r := gin.Default(); r.GET("/users", getUsers) [src1] |
PDO::query($sql) | db.QueryContext(ctx, sql) (database/sql) | rows, err := db.QueryContext(ctx, "SELECT id, name FROM users") [src6] |
Eloquent::find($id) | db.First(&user, id) (GORM) | var user User; db.First(&user, id) [src6] |
$app->middleware(fn) | r.Use(middleware) (Gin) | r.Use(gin.Logger(), gin.Recovery()) [src1] |
json_encode($data) | json.Marshal(data) | bytes, err := json.Marshal(response) |
try { } catch (Exception $e) | if err != nil { } | result, err := doSomething(); if err != nil { return err } |
$_SESSION['user'] | gorilla/sessions or JWT | session, _ := store.Get(r, "session-name") |
Auth::user() (Laravel) | Custom middleware + JWT | claims := ctx.MustGet("claims").(*Claims) |
.env / config() (Laravel) | os.Getenv() or Viper | viper.SetConfigFile(".env"); viper.ReadInConfig() |
composer require package | go get package | go get github.com/gin-gonic/[email protected] |
Blade::render('view') | html/template | tmpl.ExecuteTemplate(w, "layout.html", data) |
php artisan make:command | cobra CLI framework | rootCmd.AddCommand(migrateCmd) |
Carbon::now() | time.Now() | now := time.Now().UTC() |
$request->validate([...]) | binding tags (Gin) | type Req struct { Name string `binding:"required"` } [src1] |
php artisan migrate | golang-migrate/migrate | migrate -path db/migrations -database $DB_URL up |
Decision Tree
START
├── Is the PHP app a monolith or microservices?
│ ├── MONOLITH → Use strangler fig pattern: proxy routes to Go or PHP per endpoint [src3, src4]
│ └── MICROSERVICES → Rewrite one service at a time, starting with highest-traffic or simplest [src2]
├── What is the team's Go experience?
│ ├── NONE → Start with a small, non-critical internal tool or CLI in Go first [src2, src8]
│ └── SOME/EXPERIENCED ↓
├── What framework complexity do you need?
│ ├── MINIMAL (few routes, simple middleware) → Use net/http + chi router (Go 1.22+ has built-in path params)
│ ├── MODERATE (REST API, validation, middleware) → Use Gin (most popular, best docs) [src1]
│ ├── HIGH PERFORMANCE (>50K req/s, WebSocket) → Use Fiber (fasthttp-based)
│ └── ENTERPRISE (type safety, OpenAPI generation) → Use Echo
├── What database access pattern?
│ ├── SIMPLE QUERIES, MAX CONTROL → database/sql + sqlx [src6]
│ ├── RAPID DEVELOPMENT, FAMILIAR WITH ORMs → GORM [src6]
│ ├── COMPLEX RELATIONS, CODE-FIRST SCHEMA → Ent (Facebook/Meta)
│ └── TYPE-SAFE FROM SQL → sqlc (generates Go from SQL) [src6]
└── DEFAULT → Gin + sqlx + strangler fig behind NGINX
Decision Logic
Agent-facing if/then rules for recommending a migration path. Apply top-down; the first matching rule wins.
If the team has zero production Go experience
--> Do not migrate a critical path first. Ship one small internal CLI or a low-traffic read endpoint in Go to build fluency, then expand. [src2, src8]
If the PHP monolith still ships features daily and cannot freeze
--> Use the strangler fig pattern behind NGINX/Traefik: route migrated endpoints to Go, leave everything else on PHP-FPM, and ramp traffic 1% -> 10% -> 50% -> 100% with shadow traffic validation. Never attempt a big-bang rewrite. [src3, src4, src10]
If you need a thin REST API with validation and middleware
--> Use Gin (most popular, best docs) with sqlx for data access; reach for net/http + chi only if you want zero framework dependency on Go 1.22+. [src1, src6]
If you need maximum throughput (>50K req/s) or WebSocket/streaming
--> Use Fiber (fasthttp-based) or Echo, and lean on goroutines + errgroup for concurrent I/O — this is Go's structural advantage over single-threaded PHP-FPM. [src2, src10]
If you deploy to Kubernetes
--> Build on Go 1.25+ so container-aware GOMAXPROCS auto-respects the cgroup CPU limit; do not hard-code GOMAXPROCS or rely on automaxprocs anymore. [src9]
If the app is I/O-bound and PHP 8.4 + OPcache + JIT already meets SLOs
--> Do NOT migrate. Optimize PHP first (FrankenPHP, Swoole/RoadRunner for async) — migrating buys little and costs a rewrite. [src5, src8]
If you migrate the data layer and the team knows ORMs
--> Use GORM for speed of development, sqlc for type-safe code generation from SQL, or sqlx for PDO-like control; always share one long-lived connection pool, never open a connection per request. [src6]
Step-by-Step Guide
1. Set up the Go project alongside PHP
Create a Go module in a separate directory or repository. Both the PHP app and Go app will run simultaneously during migration, served by a reverse proxy. [src3, src4]
# Initialize Go project
mkdir go-backend && cd go-backend
go mod init github.com/yourorg/yourproject
go get github.com/gin-gonic/[email protected]
go get github.com/jmoiron/[email protected]
go get github.com/lib/[email protected]
Verify: go build ./... compiles without errors.
2. Configure a reverse proxy to split traffic
Use NGINX or Caddy to route requests — migrated endpoints go to Go, everything else goes to PHP. This is the strangler fig pattern in action. [src3, src4]
# nginx.conf — route migrated endpoints to Go, rest to PHP
upstream go_backend {
server 127.0.0.1:8080;
}
upstream php_backend {
server 127.0.0.1:9000; # PHP-FPM
}
server {
listen 80;
server_name api.example.com;
# Migrated endpoints → Go
location /api/v2/users {
proxy_pass http://go_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Everything else → PHP
location / {
fastcgi_pass php_backend;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
Verify: curl http://api.example.com/api/v2/users returns a Go response; other paths return PHP responses.
3. Replicate the database access layer
Port your PHP database queries to Go. Use sqlx for direct SQL (closest to PDO) or GORM for an ORM approach. Both apps share the same database during migration. [src5, src6]
type User struct {
ID int64 `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type UserRepository struct {
db *sqlx.DB
}
// FindByID replaces User::find($id)
func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
var user User
err := r.db.GetContext(ctx, &user,
"SELECT id, name, email, created_at FROM users WHERE id = $1", id)
if err != nil {
return nil, err
}
return &user, nil
}
Verify: go test ./models/... -v connects to your existing database and retrieves a row.
4. Implement the first API endpoint in Go
Pick a simple, high-traffic read endpoint first. This builds team confidence and provides measurable performance comparison. Muzz started with their Discover endpoint and saw a 57% P50 latency reduction. [src1, src2]
r := gin.Default()
r.GET("/api/v2/users", func(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
users, err := userRepo.List(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch users"})
return
}
c.JSON(http.StatusOK, users)
})
Verify: curl http://localhost:8080/api/v2/users?limit=5 returns JSON matching the PHP endpoint's output.
5. Port middleware (auth, logging, CORS)
Translate your PHP middleware stack to Gin middleware. Go middleware follows the same pattern — intercept the request, do work, then call c.Next(). [src1, src2]
// JWTAuth replaces Laravel's auth:api middleware
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
claims := token.Claims.(jwt.MapClaims)
c.Set("user_id", claims["sub"])
c.Next()
}
}
Verify: curl -H "Authorization: Bearer <token>" http://localhost:8080/api/v2/protected returns 200 with valid token, 401 without.
6. Migrate database schemas with golang-migrate
Replace php artisan migrate with golang-migrate for version-controlled schema changes. [src5]
# Install golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Create a migration
migrate create -ext sql -dir db/migrations -seq add_user_roles
# Run migrations
migrate -path db/migrations -database "$DATABASE_URL" up
Verify: migrate -path db/migrations -database $DATABASE_URL version shows the current migration version.
7. Containerize and deploy Go alongside PHP
Build a minimal Docker image for the Go service. Go compiles to a single static binary, so the image can be under 20 MB compared to PHP's 300+ MB. [src2, src5]
# Build stage
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Runtime stage — ~5 MB base image
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/db/migrations ./db/migrations
EXPOSE 8080
CMD ["./server"]
Verify: docker images go-backend shows image size under 20 MB. curl http://localhost:8080/health returns {"status":"ok"}.
Code Examples
Go/Gin: REST API with CRUD operations (replaces Laravel Resource Controller)
// Input: HTTP requests to /api/v2/products (GET, POST, PUT, DELETE)
// Output: JSON responses with proper status codes and error handling
type Product struct {
ID int64 `db:"id" json:"id"`
Name string `db:"name" json:"name" binding:"required"`
Price float64 `db:"price" json:"price" binding:"required,gt=0"`
SKU string `db:"sku" json:"sku" binding:"required"`
}
type ProductHandler struct {
db *sqlx.DB
}
// Index replaces ProductController::index()
func (h *ProductHandler) Index(c *gin.Context) {
var products []Product
err := h.db.SelectContext(c.Request.Context(), &products,
"SELECT id, name, price, sku FROM products ORDER BY id LIMIT 50")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, products)
}
// Store replaces ProductController::store()
func (h *ProductHandler) Store(c *gin.Context) {
var input Product
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var id int64
err := h.db.QueryRowContext(c.Request.Context(),
"INSERT INTO products (name, price, sku) VALUES ($1, $2, $3) RETURNING id",
input.Name, input.Price, input.SKU).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "insert failed"})
return
}
input.ID = id
c.JSON(http.StatusCreated, input)
}
Go: Database migration from PDO to database/sql + sqlx
// Input: PHP PDO-style query patterns
// Output: Equivalent Go database/sql + sqlx patterns with proper error handling
// Connect replaces PHP's new PDO($dsn, $user, $pass)
func Connect() (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
// Connection pool settings — Go reuses connections (unlike PHP per-request)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
// Transaction replaces PDO::beginTransaction() / commit() / rollback()
func Transaction(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
log.Printf("rollback failed: %v (original: %v)", rbErr, err)
}
return err
}
return tx.Commit()
}
Go: Middleware chain (replaces Laravel middleware stack)
// Input: HTTP request requiring rate limiting, auth, and logging
// Output: Gin middleware chain equivalent to Laravel's middleware groups
// RateLimiter replaces Laravel's ThrottleRequests middleware
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
limit int
window time.Duration
}
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
rl.mu.Lock()
v, exists := rl.visitors[ip]
if !exists || time.Since(v.lastSeen) > rl.window {
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
rl.mu.Unlock()
c.Next()
return
}
v.count++
v.lastSeen = time.Now()
if v.count > rl.limit {
rl.mu.Unlock()
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"error": "rate limit exceeded"})
return
}
rl.mu.Unlock()
c.Next()
}
}
// SetupMiddleware replaces Laravel's Kernel::$middlewareGroups
func SetupMiddleware(r *gin.Engine, jwtSecret string) {
r.Use(Logger())
r.Use(CORS())
r.Use(gin.Recovery())
rl := NewRateLimiter(60, time.Minute)
api := r.Group("/api/v2")
api.Use(rl.Middleware())
api.Use(JWTAuth(jwtSecret))
}
Anti-Patterns
Wrong: Translating PHP line-by-line to Go
// ❌ BAD — writing Go like PHP (global state, no error handling, SQL injection)
var db *sql.DB // global mutable state
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
row := db.QueryRow("SELECT * FROM users WHERE id = " + id) // SQL injection!
var user User
row.Scan(&user.ID, &user.Name) // ignoring error
json.NewEncoder(w).Encode(user) // ignoring error
}
Correct: Idiomatic Go with dependency injection and error handling
// ✅ GOOD — proper Go patterns: DI, parameterized queries, error handling
type Handler struct {
db *sqlx.DB
}
func (h *Handler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var user User
err = h.db.GetContext(c.Request.Context(), &user,
"SELECT id, name, email FROM users WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
Wrong: Opening a new DB connection per request (PHP-FPM habit)
// ❌ BAD — opening and closing DB connection every request
func handler(c *gin.Context) {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
defer db.Close()
// ... query ...
}
Correct: Use connection pooling (Go's built-in strength)
// ✅ GOOD — single connection pool shared across all goroutines
func main() {
db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL"))
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
h := &Handler{db: db}
r := gin.Default()
r.GET("/users/:id", h.GetUser)
r.Run(":8080")
}
Wrong: Using panic/recover as try/catch (PHP exception habit)
// ❌ BAD — using panic for business logic errors
func processOrder(order Order) {
if order.Total <= 0 {
panic("invalid order total")
}
}
func handler(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
c.JSON(500, gin.H{"error": r})
}
}()
processOrder(order)
}
Correct: Return errors explicitly
// ✅ GOOD — explicit error returns (the Go way)
func processOrder(order Order) error {
if order.Total <= 0 {
return fmt.Errorf("invalid order total: %.2f", order.Total)
}
return nil
}
func handler(c *gin.Context) {
if err := processOrder(order); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "processed"})
}
Wrong: Using map[string]interface{} everywhere (mimicking PHP arrays)
// ❌ BAD — dynamic typing in a statically typed language
func getConfig() map[string]interface{} {
return map[string]interface{}{
"db_host": "localhost",
"db_port": 5432,
"debug": true,
}
}
// host := cfg["db_host"].(string) // runtime panic if wrong type
Correct: Use typed structs for configuration
// ✅ GOOD — strongly typed configuration
type Config struct {
DBHost string `env:"DB_HOST" envDefault:"localhost"`
DBPort int `env:"DB_PORT" envDefault:"5432"`
Debug bool `env:"DEBUG" envDefault:"false"`
}
func LoadConfig() (*Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
Wrong: Sequential processing (PHP single-threaded habit)
// ❌ BAD — sequential I/O operations
func handler(c *gin.Context) {
users := fetchUsers() // 200ms
orders := fetchOrders() // 300ms
stats := fetchStats() // 150ms
// Total: 650ms sequential
}
Correct: Use goroutines and errgroup for concurrent fetches
// ✅ GOOD — concurrent fetches (Go's killer feature)
func handler(c *gin.Context) {
var users []User; var orders []Order; var stats Stats
g, ctx := errgroup.WithContext(c.Request.Context())
g.Go(func() error { var err error; users, err = fetchUsers(ctx); return err })
g.Go(func() error { var err error; orders, err = fetchOrders(ctx); return err })
g.Go(func() error { var err error; stats, err = fetchStats(ctx); return err })
if err := g.Wait(); err != nil {
c.JSON(500, gin.H{"error": err.Error()}); return
}
// Total: ~300ms (slowest), not 650ms
c.JSON(200, gin.H{"users": users, "orders": orders, "stats": stats})
}
Common Pitfalls
- SQL injection from string concatenation: PHP developers used to PDO prepared statements sometimes concatenate SQL strings in Go. Fix: Always use parameterized queries —
db.Query("SELECT * FROM users WHERE id = $1", id), never string formatting. [src6] - Forgetting to close sql.Rows: In PHP, PDO result sets are garbage-collected. In Go, unclosed
Rowsobjects leak database connections. Fix: Alwaysdefer rows.Close()immediately afterdb.Query(). [src6] - Not handling Go's zero values: PHP returns
nullfor unset variables. Go initializes variables to zero values. Fix: Use pointers (*string,*int) for nullable fields, or usesql.NullStringfor database columns. [src5] - Deploying without container-aware GOMAXPROCS on older Go: On Go < 1.25 the runtime defaults GOMAXPROCS to the host CPU count, not the container's CPU limit. Fix: Build on Go 1.25+ (GOMAXPROCS now reads the cgroup CPU limit automatically); on older runtimes use
go.uber.org/automaxprocsor setGOMAXPROCSexplicitly. [src2, src9] - Using the default HTTP client with no timeouts: Go's
http.DefaultClienthas no timeout, unlike PHP's cURL defaults. Fix: Always create a client with explicit timeouts —&http.Client{Timeout: 10 * time.Second}. [src1] - Migrating everything at once: Big-bang rewrites stall feature delivery and introduce massive regression risk. Fix: Migrate one endpoint at a time, keep PHP running for unmigrated routes. [src2, src3, src4]
- Not leveraging Go's concurrency: PHP developers write sequential code by habit. Fix: Use goroutines with
errgroupfor concurrent I/O operations — this is Go's biggest advantage over PHP. [src2, src5] - Running both systems without cross-team coordination: Muzz reported features being built in PHP despite the language being phased out, creating redundant work. Fix: Establish clear migration ownership and freeze non-critical PHP development early. [src2]
Diagnostic Commands
# Check Go installation and version
go version
# Verify module dependencies are resolved
go mod tidy && go mod verify
# Run all tests with race detector
go test -race ./...
# Benchmark (compare with PHP performance)
go test -bench=. -benchmem ./...
# Profile CPU usage
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
# Check for common Go mistakes
go vet ./...
# Run the Go linter
golangci-lint run ./...
# Compare response output between PHP and Go endpoints
diff <(curl -s http://localhost:9000/api/v1/users) <(curl -s http://localhost:8080/api/v2/users)
# Monitor goroutine count (detect leaks)
curl http://localhost:8080/debug/pprof/goroutine?debug=1 | head -1
# Database connection pool stats
curl http://localhost:8080/debug/db/stats
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Go 1.26 (Feb 2026) | Current | Green Tea GC on by default, heap base address randomization, crypto/hpke, net/url.Parse rejects malformed host colons | 10-40% less GC overhead (no code change); go fix adds modernizers; ~30% lower cgo overhead; deprecates ReverseProxy.Director (use Rewrite) [src9] |
| Go 1.25 (Aug 2025) | Supported | Container-aware GOMAXPROCS (respects cgroup CPU limit), testing/synctest, experimental encoding/json/v2 | Drop automaxprocs in K8s — runtime reads the cgroup limit; use sync.WaitGroup.Go(); SHA-1 disallowed in TLS 1.2 [src9] |
| Go 1.24 (Feb 2025) | Supported | Swiss Tables map implementation, go:wasmexport, tool directives in go.mod | Use go get -tool for linters/generators; 2-3% CPU overhead reduction; json:"name,omitzero" tag; os.Root for filesystem sandboxing [src7] |
| Go 1.23 (Aug 2024) | Supported | Range-over-func iterators, unique package | Use iter package for cleaner collection iteration; new Request.Pattern field in net/http |
| Go 1.22 (Feb 2024) | Maintenance | Enhanced net/http routing with path params | Can use http.NewServeMux() with {id} params — reduces need for Gin/Chi for simple APIs |
| Go 1.21 (Aug 2023) | EOL | log/slog added, maps/slices packages | Replace log.Printf with slog.Info() for structured logging |
| Go 1.18 (Mar 2022) | EOL | Generics introduced | Enables type-safe utility functions |
| PHP 8.4 (Nov 2024) | Current source | Property hooks, asymmetric visibility, new DOM API | Consider PHP 8.4 features before deciding to migrate — property hooks reduce boilerplate significantly |
| PHP 8.3 (Nov 2023) | Supported | Typed class constants, json_validate() | PHP performance has improved with JIT — verify migration is still warranted |
| PHP 7.4 (Nov 2019) | EOL | Typed properties, arrow functions | If stuck on PHP 7.x, migration urgency is higher due to security EOL |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| PHP app has hit CPU/memory scaling limits under high concurrency (>1K req/s) | PHP app handles load fine with PHP 8.4 + OPcache + FrankenPHP | Optimize PHP first (OPcache, JIT, connection pooling) |
| Building new microservices alongside existing PHP monolith | Entire team knows only PHP and timeline is tight | Stay with PHP, consider Swoole/RoadRunner for async |
| Need sub-millisecond response times or WebSocket/streaming support | Building a content-heavy website (CMS, blog, e-commerce) | Laravel/WordPress with caching layer |
| Deploying to Kubernetes and want minimal container images (<20 MB vs 300+ MB) | Startup/MVP needing rapid prototyping | Keep PHP (Laravel) or consider Node.js |
| Team already has Go experience or is eager to learn | Heavily invested in Laravel ecosystem (Nova, Horizon, Forge, Vapor) | Continue with Laravel, add Go for bottleneck services |
| Reducing infrastructure costs (Go uses 5-10x less memory per request than PHP-FPM) | Application is I/O bound, not CPU bound | PHP 8.4 with async (Swoole) or Node.js |
Important Caveats
- Go has no generics-based ORM as mature as Laravel's Eloquent — expect to write more SQL or use code generation tools like sqlc. The trade-off is more code but more control and better performance. [src6]
- PHP's exception-based error handling does not map to Go — you must adopt Go's explicit
if err != nilpattern throughout. Using panic/recover as a substitute is an anti-pattern. [src5, src8] - Go binaries are statically linked and self-contained — there is no equivalent of
php artisan serveduring development. Use tools likeairorgowfor hot-reload. - The PHP-to-Go migration is a one-way door — once team skills shift to Go, maintaining PHP becomes increasingly expensive. Plan for full migration, not permanent dual-stack. [src2]
- Go's standard library
net/httpis production-ready. Unlike PHP where you almost always need a framework, simple Go APIs can use only the standard library with Go 1.22+'s enhanced router. [src1, src7] - Go 1.24 introduced Swiss Tables for maps (faster hash maps),
os.Rootfor filesystem sandboxing, andomitzeroJSON tag — these simplify migration of PHP patterns. [src7] - Go 1.25 made
GOMAXPROCScontainer-aware: on Linux it now reads the cgroup CPU bandwidth limit, so the long-standinggo.uber.org/automaxprocsworkaround is no longer needed on Go 1.25+. Go 1.26 then made the Green Tea garbage collector the default, cutting GC overhead 10-40% with no code change — both directly benefit high-concurrency services ported from PHP-FPM. [src9] - Go 1.26 deprecated
httputil.ReverseProxy.Directorin favor ofReverseProxy.Rewrite; if you front your PHP backend with a Go-based proxy during the strangler-fig phase, useRewrite. [src9] - Muzz's migration showed 57% P50 latency reduction but noted the hardest part was keeping both systems running in parallel while shipping features. Budget 2-3x the estimated migration time, and ramp Go traffic gradually (1% -> 10% -> 50% -> 100%) with shadow traffic before promoting each endpoint. [src2, src10]