May 1, 2026

What actually changes when you move from PHP to Go

phpgobackend
PHP to Go cover graphic for erkshitiz.com.np

I spent my first couple of years writing PHP and Laravel, then moved into Go once I started building backend services on AWS. A lot of the underlying skills carried over: thinking in terms of requests and responses, database schemas, caching layers. What surprised me was how much of the day-to-day habit had to change even though the job title stayed “backend developer.”

Error handling stops being optional

In PHP, a lot of error handling can be deferred to a framework-level exception handler. You throw, Laravel catches it somewhere up the stack, logs it, and returns a 500. It works, and it means you can skip handling errors you consider unlikely.

Go does not give you that escape hatch. Every function that can fail returns an error explicitly, and the compiler does not force you to check it, but the convention does:

user, err := repo.FindUser(ctx, id)
if err != nil {
	return nil, fmt.Errorf("find user %s: %w", id, err)
}

At first this feels like busywork compared to a try/catch block wrapping a whole method. After a few months it becomes the thing I miss most when I go back to PHP: every call site that can fail is visibly marked, and there is no invisible exception jumping five stack frames up to a handler you forgot existed.

No framework doing things for you

Laravel gives you routing, an ORM, validation, queues, and a dozen other things out of the box, all wired together with conventions. Go’s standard library gives you an HTTP server and not much else. Everything past that, routing, database access, request validation, is a library you choose and wire up yourself, or code you write by hand.

This is slower to start a new project, and it is also the thing that made me understand what Laravel was actually doing underneath. Writing your own middleware chain once makes every framework’s middleware system make more sense afterward.

Concurrency is a first-class tool, not an escape hatch

PHP-FPM’s model is one request, one process, no shared state between requests unless you reach for Redis or a database. Concurrency within a request basically does not exist unless you are doing something unusual.

In Go, goroutines are cheap enough that fanning out work within a single request is completely normal:

func loadDashboard(ctx context.Context, userID string) (*Dashboard, error) {
	var wg sync.WaitGroup
	var stats Stats
	var recent []Activity
	var statsErr, recentErr error

	wg.Add(2)
	go func() {
		defer wg.Done()
		stats, statsErr = loadStats(ctx, userID)
	}()
	go func() {
		defer wg.Done()
		recent, recentErr = loadRecentActivity(ctx, userID)
	}()
	wg.Wait()

	if statsErr != nil {
		return nil, statsErr
	}
	if recentErr != nil {
		return nil, recentErr
	}
	return &Dashboard{Stats: stats, Recent: recent}, nil
}

That pattern, load two independent things in parallel and join them before responding, would be an unusual thing to reach for in a PHP request handler. In Go it is routine, and it changes how you design a service: you start looking for the independent pieces of work inside a single request instead of assuming everything has to happen in sequence.

What stayed the same

Database design did not change. Thinking about indexes, N+1 queries, and transaction boundaries transferred directly. So did the instinct to keep business logic out of the framework layer, whether that layer is a Laravel controller or a Go HTTP handler. The language changed, the actual engineering judgment mostly did not.