go mod init yourproject && go get github.com/gin-gonic/ginhtml/template is intentionally minimal — there is no Blade/Twig equivalent. Server-rendered UIs require a fundamentally different approach or a separate frontend (React, Vue, htmx). [src8]sqlx for direct SQL, GORM for rapid development, or sqlc for type-safe code generation from SQL. [src6]if err != nil returns throughout. Using panic/recover as a substitute is an anti-pattern. [src5, src8]composer autoload or dynamic class loading. All dependencies are compiled in at build time.| 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 |
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
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.
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.
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.
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.
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.
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.
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"}.
// 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)
}
// 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()
}
// 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))
}
// ❌ 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
}
// ✅ 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)
}
// ❌ 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 ...
}
// ✅ 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")
}
// ❌ 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)
}
// ✅ 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"})
}
// ❌ 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
// ✅ 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
}
// ❌ BAD — sequential I/O operations
func handler(c *gin.Context) {
users := fetchUsers() // 200ms
orders := fetchOrders() // 300ms
stats := fetchStats() // 150ms
// Total: 650ms sequential
}
// ✅ 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})
}
db.Query("SELECT * FROM users WHERE id = $1", id), never string formatting. [src6]Rows objects leak database connections. Fix: Always defer rows.Close() immediately after db.Query(). [src6]null for unset variables. Go initializes variables to zero values. Fix: Use pointers (*string, *int) for nullable fields, or use sql.NullString for database columns. [src5]go.uber.org/automaxprocs or set GOMAXPROCS explicitly. [src2]http.DefaultClient has no timeout, unlike PHP's cURL defaults. Fix: Always create a client with explicit timeouts — &http.Client{Timeout: 10 * time.Second}. [src1]errgroup for concurrent I/O operations — this is Go's biggest advantage over PHP. [src2, src5]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Go 1.24 (Feb 2025) | Current | 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) | Supported | 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) | Maintenance | 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 |
| 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 |
if err != nil pattern throughout. Using panic/recover as a substitute is an anti-pattern. [src5, src8]php artisan serve during development. Use tools like air or gow for hot-reload.net/http is 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]os.Root for filesystem sandboxing, and omitzero JSON tag — these simplify migration of PHP patterns. [src7]