- Book: Observability for LLM Applications · Ebook from Apr 22
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
The Laravel container. Eloquent. Facades. Magic methods. Thirty years of PHP have taught you that the framework does the composition for you. You type User::find(1) and something, somewhere, boots an ORM, resolves a connection, hydrates a model, and hands it back. You never had to ask who wired what.
Go hands the composition back. Every dependency is a parameter. Every request is a goroutine. There is no container that calls new on your behalf, no __construct metadata, no framework that reads your route annotations at boot.
This is the part of the move that actually hurts. Not the syntax. The syntax is small. The mental model is the thing. Here is what that looks like in practice, feature by feature, for a PHP developer who already ships.
flowchart LR
subgraph PHP["PHP / PHP-FPM"]
R1[Request] --> W1[Spawn worker]
W1 --> P1[Boot framework<br/>Load config]
P1 --> H1[Handle request]
H1 --> D1[Die]
end
subgraph GO["Go / net/http"]
R2[Request] --> G2[Go routine]
G2 --> H2[Handle request]
H2 --> F2[Return<br/>routine freed]
end
The request lifecycle stops dying
In PHP-FPM, every request is a short-lived process. It boots the framework, handles one HTTP call, and dies. State does not survive. Caches need Redis. Background work needs a queue worker. You never had to think about a request that outlives its handler because nothing ever did.
A Go server is one long-lived process. Every request is a goroutine — roughly 2 KB of stack, scheduled on top of a small pool of OS threads by the Go runtime. The process boots once. Globals survive. In-memory caches work. The http.Server you start in main runs until you SIGTERM it.
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
That is the whole server. No index.php, no public/ directory, no .htaccess. The Laravel equivalent has a router, a kernel, middleware pipeline, service providers, and PHP-FPM in front of it. Go skips all of that because it doesn't need to reboot every request.
RoadRunner and FrankenPHP narrow the gap on the PHP side. They keep the worker alive between requests. But the default PHP model is still die-on-response, and most Laravel code is written as if that's true — globals are suspect, singletons need ->singleton() bindings, and memory leaks "don't exist" because the process gets flushed. In Go, none of that applies. A leaked goroutine will still be there at 3 a.m.
Eloquent vs database/sql + sqlc
This is the habit that breaks hardest. Eloquent and Doctrine let you write:
$users = User::where('active', true)
->with('orders')
->orderBy('created_at', 'desc')
->limit(20)
->get();
And you mostly stop thinking about the SQL. The ORM lazy-loads, eager-loads, and builds the query for you.
Go has ORMs (GORM, ent, Bun). Most production Go code does not use them. The pattern most teams coming from Laravel converge on is sqlc — write SQL by hand, generate typed Go functions from it, call the functions.
-- queries.sql
-- name: ListActiveUsersWithOrders :many
SELECT u.id, u.email, u.created_at
FROM users u
WHERE u.active = true
ORDER BY u.created_at DESC
LIMIT $1;
// generated by sqlc — you do not write this file
users, err := q.ListActiveUsersWithOrders(ctx, 20)
if err != nil {
return err
}
for _, u := range users {
// u.ID is int64, u.Email is string, u.CreatedAt is time.Time
}
The SQL lives in a file. The types live in generated Go. You can grep for every query in the codebase. No lazy loading, no N+1 hidden behind a property access, no tap and dd to inspect what the ORM did — the query is literally the file you opened.
If you've been fighting Eloquent for years over whereHas generating awful joins, this feels like leaving a loud room.
Dependency injection: the container is gone
Laravel's service container is one of its best ideas. You bind an interface, type-hint a constructor, and the framework resolves the graph for you. It feels like magic because it reads reflection metadata at runtime.
Go has no reflection-based container in the standard library. You wire your graph by hand, in main, and pass dependencies down as arguments.
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
queries := sqlc.New(db)
mailer := smtp.NewMailer(os.Getenv("SMTP_URL"))
userSvc := user.NewService(queries, mailer)
handler := httpapi.NewHandler(userSvc)
log.Fatal(http.ListenAndServe(":8080", handler))
}
That main is your composition root. Every dependency is visible. Nothing is auto-wired. If you want to swap the mailer for a test fake, you pass a different mailer into user.NewService.
Laravel devs usually hate this for a week and then find it restful. You stop grepping for ->bind() calls in twelve different service providers. You stop wondering whether a test is getting the real Mailer or a spy. The graph is the code in main.
Tools like wire exist to generate this wiring at compile time when the graph gets big, but the generated output is still plain func main() code you can read.
Middleware: http.Handler is the whole pattern
Laravel middleware looks like this:
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()) {
return redirect('/login');
}
return $next($request);
}
Go's http.Handler is the same idea with one less layer of abstraction. A middleware is a function that takes a handler and returns a handler.
func RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-ID")
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, uid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// wire the chain
handler := RequireUser(Logging(apiHandler))
No $kernel->pushMiddleware(...), no priority ordering, no group names. The chain is function composition. You can read the whole pipeline in the file where you build it.
The tradeoff: Laravel's named middleware groups and route-level middleware declarations are genuinely more ergonomic for a team of 20 people who all need to add auth to a new route. Go needs a router like chi or echo before you get that back.
Concurrency: goroutines are not workers
PHP's async story is fragmented. Fibers landed in PHP 8.1. Amphp and ReactPHP exist. Swoole and OpenSwoole give you coroutines. Most production PHP apps still dispatch long work to a queue and let Horizon or a custom worker eat it.
In Go, concurrency is a language feature. You write:
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
results := make([]string, len(urls))
errs := make(chan error, len(urls))
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func(i int, u string) {
defer wg.Done()
body, err := fetch(ctx, u)
if err != nil {
errs <- err
return
}
results[i] = body
}(i, u)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return nil, err
}
}
return results, nil
}
No queue. No worker. No serialization. Twelve HTTP calls fan out, run concurrently on the Go scheduler, and come back.
This is the capability PHP genuinely cannot match without bolt-ons. If your current Laravel code dispatches 12 jobs and polls for completion, the Go version is one function.
The warning: go somefunc() with no coordination is how Go services leak goroutines and eat memory. Every goroutine needs a way to finish — usually a context.Context with a deadline, or a channel close, or a sync.WaitGroup. "Just spawn a goroutine" is the Go equivalent of "just fire and forget a queue job" and it causes similar classes of bugs.
Typing: PHP 8.4 is close, but not the same thing
PHP 8.4 has declare(strict_types=1), typed properties, union types, readonly classes, and asymmetric visibility. You can write PHP that looks a lot like TypeScript.
Go's type system is cruder but fully static and fully compiled. No strings to int coercion, no array as both list and map, no variadic arrays of whatever. A []User is a slice of User. A map[string]int is a map from string to int. The compiler refuses to build if the types don't fit.
What PHP has that Go doesn't: named arguments, default parameter values, optional arguments. In Go, you build a struct.
type CreateUserParams struct {
Email string
Password string
Role string // defaults to "member" if empty
}
func CreateUser(ctx context.Context, p CreateUserParams) (*User, error) {
if p.Role == "" {
p.Role = "member"
}
// ...
}
// call site
u, err := CreateUser(ctx, CreateUserParams{
Email: "a@b.com",
Password: "hunter2",
})
Verbose compared to createUser(email: 'a@b.com', password: 'hunter2') in PHP. You get used to it. The payoff is that refactoring a function signature across a 200-file codebase is a compiler job, not a grep job.
Testing: the stdlib is enough
PHPUnit and Pest are mature, opinionated, and do a lot for you — data providers, mocking, snapshot testing, beautiful output. Pest in particular reads nicely.
Go's testing package is deliberately plain. No framework. No mocking library in the stdlib. Table tests are the idiom.
func TestNormalizeEmail(t *testing.T) {
cases := []struct {
in, want string
}{
{"A@B.com", "a@b.com"},
{" x@y.io ", "x@y.io"},
{"", ""},
}
for _, c := range cases {
got := NormalizeEmail(c.in)
if got != c.want {
t.Errorf("NormalizeEmail(%q) = %q, want %q", c.in, got, c.want)
}
}
}
Run with go test ./.... No config file. The test is a function. Subtests, benchmarks, fuzzing, race detection, coverage — all in the stdlib or one flag away.
Teams coming from Pest usually miss the expressiveness for the first month. Then they stop noticing.
Five pitfalls PHP devs hit in their first Go week
Ignoring errors. In PHP you throw and catch. In Go, every call that can fail returns an
error, and the compiler lets you drop it with_. Do not drop it. A_ = json.Unmarshal(...)is a silent data bug waiting to ship.Sharing a struct across goroutines without a mutex. Every PHP request gets its own process. Shared state was Redis, period. A Go handler runs concurrently with every other handler, and a map you read without a lock will eventually panic under load.
Treating
nillikenull. A typednilinside an interface is not equal to a plainnil.var err *MyError = nil; var e error = err; e == nilisfalse. This trips every PHP dev once.Over-using packages. Laravel encourages small service classes. Go packages are heavier — each is a compilation unit and an import. A PHP app with 200 classes in 40 namespaces maps to maybe 6 Go packages, not 40.
Building a container. Someone on the team, by week two, will try to port Laravel's container to Go using reflection. Do not. Pass dependencies as arguments. The one-time refactor pain saves a year of debugging "which binding did the container resolve at 3 a.m."
What Laravel still does better
Be honest about this. Laravel has things Go will not give you back:
-
Artisan.
php artisan make:controller,make:migration,tinker. Go hasgo run,go generate, and whatever CLI you build yourself. There is no REPL you can fire up to poke production data. -
Eloquent for CRUD apps. If you are building an admin panel and 80% of your code is
Model::where(...)->update(...), Eloquent is faster to write than any Go ORM. -
Blade. Go's
html/templateis safe and decent. It is not Blade. - The ecosystem. Laravel Nova, Filament, Livewire, Inertia, Horizon, Forge, Vapor. The Go ecosystem has nothing like the batteries-included admin and deploy story.
-
Conventions. A new Laravel dev can find the
UserControllerin any app because it is always in the same place. Go projects disagree about project layout — there is one community proposal and plenty of teams who reject it.
What Go does that PHP genuinely cannot
The list is shorter but load-bearing:
-
A single static binary.
go buildproduces one file. Ship it. No PHP version, no extensions, no FPM pool config, no Composer install on the server. - Real concurrency. See the fan-out example above.
- Predictable memory and GC. A Go service at 200 RPS uses 80 MB and stays there. A Laravel FPM pool at the same load needs to fork workers and eats multiples of that.
- The compile step. Most of the "what does this function take" questions are answered before runtime. This is the thing that scales a codebase past 20 engineers.
-
Standard tooling.
go fmtis not negotiable.go vet,go test -race,go test -coverare all stdlib. PHP has PHP-CS-Fixer, PHPStan, Psalm, Rector, PHPUnit, Xdebug, Pest — pick your stack and argue about it for a year.
The honest summary for a PHP dev eyeing Go
You are not learning a better PHP. You are learning a language designed by people who wanted to delete most of what PHP gives you for free, because at a certain scale the magic costs more than it saves.
The first week hurts. You will type User::find(1) into an empty file and stare at it. You will write twelve lines of explicit wiring where Laravel would have written zero. You will forget to check an error and spend 40 minutes debugging silent data.
The second week, something clicks. The wiring you wrote by hand is the wiring. The function signature is the contract. The test is a function. The server is one process. And when you push to prod, one binary goes with it.
If you want the long version of this — the full "write a production Go service from scratch" arc, with the patterns Laravel devs specifically need unlearned and the Go idioms that replace them — the Thinking in Go series is written for exactly this reader.
flowchart TD
subgraph Laravel["Laravel container"]
Bind[service bindings] --> Reflect[Reflection resolves<br/>dependencies]
Reflect --> Magic[auto-wired Controller]
end
subgraph GoMain["Go main.go"]
Cfg[load config] --> DB[open DB]
DB --> Repo[NewUserRepo]
Repo --> Svc[NewUserService]
Svc --> Handler[NewUserHandler]
end
If this was useful
Thinking in Go is the 2-book series built for developers coming to Go from a framework-heavy background. The Complete Guide to Go Programming walks through the language end to end — the one you want when Eloquent habits keep showing up in your Go code. Hexagonal Architecture in Go is the follow-up for when you need a real project layout.
Observability for LLM Applications is the other book, for engineers running LLM features in production (many of those services are written in Go).
- Thinking in Go (series): Complete Guide to Go Programming · Hexagonal Architecture in Go
- Observability for LLM Applications: Amazon · Ebook from Apr 22
- Hermes IDE: hermes-ide.com — an IDE for developers shipping with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
This article was originally published by DEV Community and written by Gabriel Anhaia.
Read original article on DEV Community
