How to Migrate a PHP Backend to Go

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

TL;DR

Constraints

Quick Reference

PHP PatternGo EquivalentExample
$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 JWTsession, _ := store.Get(r, "session-name")
Auth::user() (Laravel)Custom middleware + JWTclaims := ctx.MustGet("claims").(*Claims)
.env / config() (Laravel)os.Getenv() or Viperviper.SetConfigFile(".env"); viper.ReadInConfig()
composer require packagego get packagego get github.com/gin-gonic/[email protected]
Blade::render('view')html/templatetmpl.ExecuteTemplate(w, "layout.html", data)
php artisan make:commandcobra CLI frameworkrootCmd.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 migrategolang-migrate/migratemigrate -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

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.24-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

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

VersionStatusBreaking ChangesMigration Notes
Go 1.24 (Feb 2025)CurrentSwiss Tables map implementation, go:wasmexport, tool directives in go.modUse 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)SupportedRange-over-func iterators, unique packageUse iter package for cleaner collection iteration; new Request.Pattern field in net/http
Go 1.22 (Feb 2024)SupportedEnhanced net/http routing with path paramsCan use http.NewServeMux() with {id} params — reduces need for Gin/Chi for simple APIs
Go 1.21 (Aug 2023)Maintenancelog/slog added, maps/slices packagesReplace log.Printf with slog.Info() for structured logging
Go 1.18 (Mar 2022)EOLGenerics introducedEnables type-safe utility functions
PHP 8.4 (Nov 2024)Current sourceProperty hooks, asymmetric visibility, new DOM APIConsider PHP 8.4 features before deciding to migrate — property hooks reduce boilerplate significantly
PHP 8.3 (Nov 2023)SupportedTyped class constants, json_validate()PHP performance has improved with JIT — verify migration is still warranted
PHP 7.4 (Nov 2019)EOLTyped properties, arrow functionsIf stuck on PHP 7.x, migration urgency is higher due to security EOL

When to Use / When Not to Use

Use WhenDon't Use WhenUse 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 + FrankenPHPOptimize PHP first (OPcache, JIT, connection pooling)
Building new microservices alongside existing PHP monolithEntire team knows only PHP and timeline is tightStay with PHP, consider Swoole/RoadRunner for async
Need sub-millisecond response times or WebSocket/streaming supportBuilding 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 prototypingKeep PHP (Laravel) or consider Node.js
Team already has Go experience or is eager to learnHeavily 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 boundPHP 8.4 with async (Swoole) or Node.js

Important Caveats

Related Units