- Book: The Complete Guide to Go Programming
- 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
It is Monday morning. You have shipped PHP for years. Laravel, Symfony, a couple of legacy CRM monoliths if you have been around long enough. Today the new repo is in Go.
You open the editor. You type the first line of a function and your fingers do something wrong. There is no $. There is no try. There is no Laravel dd(). The thing the LSP keeps underlining is the variable you declared and forgot to use, which Go treats as a compile error.
This post is about that first week. Not a Laravel-to-Go port. Not an architecture diagram. The developer at the keyboard, and the muscle memory that breaks first.
1. The var_dump reflex
You hit a problem. Your hands type var_dump($user) or dd($user) and you read the output in the browser. Done in two seconds.
function checkout(Cart $cart): Order {
dd($cart->items);
// ...
}
In Go, dd() does not exist. Neither does var_dump. The first thing you Google is "Go var_dump" and you land on fmt.Printf with the %v verbs.
func checkout(ctx context.Context, cart Cart) (Order, error) {
fmt.Printf("%+v\n", cart.Items)
// ...
}
The verbs that matter on day one: %v is the default, %+v adds field names, %#v is the Go-syntax dump that comes closest to var_export. For deep structs there is the third-party davecgh/go-spew which gives you indented, fully expanded output.
The unlock: you stop dumping into HTTP responses and start writing real test cases. t.Logf("%+v", got) inside a go test run prints exactly the same data, in a context where you can also assert on it. The dump becomes a side effect of a test, not the way you interact with the program.
2. The exception reflex
In PHP, errors are something other code throws. You write the happy path, slap a try block around the dangerous bit, and let a global handler render the 500.
public function pay(int $orderId): Receipt {
$order = Order::findOrFail($orderId);
$charge = $this->stripe->charge($order->total);
return Receipt::for($order, $charge);
}
If findOrFail throws, Laravel maps it to a 404. If Stripe throws, the global handler logs it and returns 500. You wrote three lines and got error handling for free.
Go has panic, but panic is for unrecoverable bugs, not for "the order does not exist". Every function that can fail returns an explicit error as the last value.
func (s *Service) Pay(ctx context.Context, orderID int64) (Receipt, error) {
order, err := s.orders.Find(ctx, orderID)
if err != nil {
return Receipt{}, fmt.Errorf("find order %d: %w", orderID, err)
}
charge, err := s.stripe.Charge(ctx, order.Total)
if err != nil {
return Receipt{}, fmt.Errorf("charge: %w", err)
}
return Receipt{Order: order, Charge: charge}, nil
}
The first reaction is "this is so much code". It is. The second reaction, around day three, is realising the compiler will not let you ignore an error unless you write _ = on purpose. Every failure mode is in the function body, in the order it can happen.
The shift, once it lands, is that errors stop living on a separate plane of execution. They are values. You return them, you wrap them with %w so the caller can errors.Is them, and the trace points to the function call you wrote, not to a frame inside a global exception handler. The flame graph in production stops being "where did the exception come from" and starts being "which call returned an error and what did the next layer do with it".
3. The autoload reflex
Composer trained you to type use App\Services\Mailer and stop thinking. PSR-4 maps the namespace to a file path. Composer's autoloader resolves it at runtime. You never require anything.
use App\Services\Mailer;
class CheckoutController {
public function __construct(private Mailer $mailer) {}
}
Go has no autoloader. The compiler reads imports, walks the dependency graph, and links it all into a single binary. You import packages by their module path and Go fetches them.
package checkout
import (
"context"
"github.com/example/shop/internal/mailer"
)
type Controller struct {
mailer *mailer.Mailer
}
What changes for your hands: there is no composer require followed by edits to composer.json. You write the import, run go mod tidy, and go.mod updates itself. There is no vendor/autoload.php. There is no class-not-found at runtime. If it is not found, the build fails before anything ships.
What you get: a file you can read top-to-bottom and know every external thing it touches. The import block is the contract with the rest of the world. No globals injected through a magic loader, no service-provider hidden registration, no annotation scanner reading reflection at boot.
4. The app() reflex
This one is hard. Laravel's container is a beautiful tool. You type app(Mailer::class) or type-hint a constructor and the framework walks the dependency graph for you.
class CheckoutService {
public function __construct(
private Mailer $mailer,
private OrderRepository $orders,
private PaymentGateway $payments,
) {}
}
// somewhere else
$svc = app(CheckoutService::class); // resolved
Go has no container in the standard library. There is no reflection-based auto-wiring. Your composition root is func main(), and every dependency is an argument.
func main() {
db := mustOpenDB(os.Getenv("DATABASE_URL"))
defer db.Close()
orders := orderrepo.New(db)
mailer := mailer.New(os.Getenv("SMTP_URL"))
payments := stripe.New(os.Getenv("STRIPE_KEY"))
checkout := checkout.NewService(orders, mailer, payments)
handler := httpapi.NewHandler(checkout)
log.Fatal(http.ListenAndServe(":8080", handler))
}
The first day you write this, you count the lines and feel cheated. The second week, a teammate asks "where does the mailer come from in tests" and you say "argument three of NewService" without grepping anything.
The dependency graph now lives in a file you can read. No scanning service providers, no tap() to inspect bindings, no production bug from "we bound the singleton in the wrong order". For projects that grow, google/wire generates this same wiring at compile time, and the output is plain Go you can still open and read.
5. The "everything is an array" reflex
In PHP, the array is one structure that does everything. Indexed list, associative map, ordered set, function-return-bag, JSON shape. You shape it as you go.
$user = [
'id' => 1,
'email' => 'a@b.com',
'roles' => ['admin', 'editor'],
];
Go splits the abstraction in two. A slice is an ordered list of one type. A map is a key-value dictionary with declared key and value types. Mixed-type bags need a struct.
type User struct {
ID int64
Email string
Roles []string
}
u := User{
ID: 1,
Email: "a@b.com",
Roles: []string{"admin", "editor"},
}
array_merge no longer joins a list and a map. $user['emial'] against a struct is a compile error, not a silent null. And the lazy escape hatch — return array and remember the shape — does not exist.
After a week: the shape of every value is in the type, not in your memory. When you change a field name, the compiler walks every call site for you. The "what does this function actually return" question is answered by the signature, not by reading the body.
6. The "models do everything" reflex
Eloquent and Doctrine train you to attach behaviour to models. User::find(1)->orders()->where('paid', true)->sum('total') is one line and it reads like English.
$total = User::find($id)
->orders()
->where('paid', true)
->sum('total');
Go does not give you this. There is no ActiveRecord pattern in the stdlib. There is database/sql, which gives you a connection and a query method, and there are tools like sqlc and sqlx on top.
In queries.sql:
-- name: SumPaidOrderTotalsForUser :one
SELECT COALESCE(SUM(total), 0)::bigint
FROM orders
WHERE user_id = $1 AND paid = true;
total, err := q.SumPaidOrderTotalsForUser(ctx, userID)
if err != nil {
return 0, fmt.Errorf("sum totals: %w", err)
}
The query is a string in a file. The function is generated from that string. Types are real Go types. You do not chain where() calls into an opaque builder.
The unlock: you can grep every SQL statement in the codebase. You can hand the query to a DBA. Lazy loading does not exist, so N+1 problems become loud. They show up as a literal loop with a literal query. No ->with() you forgot to type. If you have spent any time fighting Eloquent over surprise joins, this feels like leaving a loud room.
7. The "queue this job" reflex
When a Laravel request needs to do work that is too slow for the response, you dispatch a job.
SendInvoiceEmail::dispatch($order)->onQueue('emails');
Horizon picks it up. A worker process pulls the job. Lifecycle handled. Concurrency handled. Retries handled. You moved on.
In Go, the request handler can do that work in the same process. A goroutine is a function call prefixed with go. The runtime schedules it onto a small pool of OS threads. Initial stack is on the order of 2 KB per goroutine, which the Go runtime grows on demand.
func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) {
order, err := h.svc.Pay(r.Context(), parseID(r))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.mailer.SendInvoice(ctx, order); err != nil {
log.Printf("send invoice: %v", err)
}
}()
writeJSON(w, http.StatusOK, order)
}
That works for fire-and-forget local work. For anything that needs durability across restarts you still want a queue (Asynq, river, NATS, SQS), but the bar to launch a piece of background work has dropped from "stand up Horizon" to "type go".
The trade: the boundary between sync and async is no longer a process boundary. Concurrency becomes a control-flow tool you reach for inside one binary.
The warning, before you spawn a thousand goroutines: every goroutine needs a way to finish. Pass a context.Context with a deadline. Close channels. Use sync.WaitGroup. A goroutine without an exit path is a memory leak that will be there at 3 a.m., and unlike PHP-FPM, no process is going to die and clean it up for you.
8. The "request dies, state dies" reflex
PHP-FPM trained you that every request is a fresh, short-lived process. Boot the framework, handle one HTTP call, die. Globals are suspect. In-memory caches do not survive. Singletons need framework support because the language does not give them to you.
// you do not write this in PHP-FPM and expect it to survive a request
class CounterService {
private int $count = 0;
public function inc(): int { return ++$this->count; }
}
A Go http.Server is one long-lived process. It boots once. The mux you build, the connection pool, the in-memory LRU you allocate at startup all survive between requests, for the lifetime of the binary.
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() int {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
return c.count
}
Two things change for your hands. That sync.Mutex matters: two requests are two goroutines hitting the same struct concurrently, and a map you read without a lock is a runtime panic waiting to happen. In-memory caches are also real now — you can keep a hot config in a sync.Map for the life of the process and never touch Redis for it.
The unit of "thing that owns memory" stops being the request. It is the process. Whole categories of design open up because state survives: connection pools, prepared statements, in-memory rate limiters, hot caches. The cost is that leaks survive too. RoadRunner and FrankenPHP narrow this gap, but the default PHP mental model still assumes die-on-response and a lot of Laravel code was written under that assumption.
9. The descriptive-name reflex
Ten years of PHP best-practice told you to write $customerEmailAddress instead of $e. PSR-12 has opinions. Your senior reviewers had stronger opinions.
foreach ($recentlyPaidInvoices as $recentlyPaidInvoice) {
$invoiceTotalAmount = $recentlyPaidInvoice->getTotal();
// ...
}
Then you read your first idiomatic Go file and the variable names are one letter long.
for _, inv := range paid {
t := inv.Total()
// ...
}
The Go style guide actively recommends short names in narrow scopes: i, r, w, ctx, err. r for *http.Request, w for http.ResponseWriter, ctx for context.Context. The convention is that scope size and name length should be proportional. A one-line loop variable can be one letter. A package-level function exported across a codebase needs a real name.
What changes: code starts to look denser, not noisier. You stop reading variable names as documentation and start reading types and call sites for that. Once you trust the compiler, you do not need the variable name to also tell you "this is a customer's email", because the type is Email and the field is email on a Customer and the function is named SendWelcome.
Just write the explicit thing. The compiler will tell you when it is wrong.
If this was useful
The Complete Guide to Go Programming is the first half of Thinking in Go. It walks the language end to end with the PHP-trained reader specifically in mind — the chapters on errors, packages, and concurrency are written against the habits this post lists. Hexagonal Architecture in Go is the follow-up for the project layout and persistence shape that come next.
- Thinking in Go (series): Complete Guide to Go Programming · Hexagonal Architecture in Go
- Hermes IDE: hermes-ide.com — an IDE for developers shipping with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub

Top comments (0)