/posts/go-from-a-php-programmer
Go from a PHP Programmer
TL;DR
Switching from PHP to Go is not a syntax change -- it is a mental model change across error handling, types, interfaces, and concurrency.
Guide
Write a PHP application and deploy it. Every request spawns a fresh process, shares nothing with the last one, and the language lets you stuff anything into an array without complaint. Write a Go service and deploy it. It starts once, runs forever, shares memory across every goroutine, and the compiler rejects code that misuses a type. The instincts PHP built -- flexible containers, exception propagation, class hierarchies, implicit isolation -- produce code that compiles in Go and breaks in production. Four mental model shifts cover most of the damage: how types encode intent, how errors travel through call stacks, what interfaces describe, and what shared memory requires. Get those four right and you stop writing PHP in Go syntax.
map[string]interface{} Compiles, Then Panics
PHP arrays hold anything. Indexed values, associative keys, nested arrays, objects -- all in one container. No declaration, no schema:
$bag = [];
$bag[] = "indexed";
$bag['key'] = "associative";
$bag[] = ["nested", "array"];
$bag['obj'] = new stdClass();
The PHP instinct in Go is map[string]interface{}. It compiles. It runs. Then a key is absent or a value is the wrong type, and the program panics at runtime:
data := map[string]interface{}{
"user": map[string]interface{}{
"name": "John",
"age": 30,
"tags": []interface{}{"admin", "active"},
},
}
// type assertion -- panics if key missing or type wrong
userName := data["user"].(map[string]interface{})["name"].(string)
Every .(string) is a type check deferred to runtime. You shipped a potential crash disguised as working code. The compiler saw interface{} and stopped helping.
Heterogeneous data means you have not modeled the domain. Model it, and the compiler does the rest:
type UserData struct {
Users []User
Metadata map[string]string
Tags []string
}
func processUsers(data UserData) {
for _, user := range data.Users {
fmt.Println(user.Name) // compile-time guarantee
}
}
Every type assertion you remove is a runtime panic you cannot have. The compiler checks types at build time -- but only when you give it typed structures to reason about. interface{} is an escape hatch, not a foundation.
The
anyalias (Go 1.18+) is identical tointerface{}. Renaming the escape hatch does not make it safer. If you find yourself writingmap[string]anyfor domain data, stop and define a struct.
Errors Are Values You Return, Not Exceptions You Throw
Call a PHP function that fails. Two things happen: it returns false and you forget to check, or it throws an exception that propagates up the stack until something catches it. The catch block lives physically away from the failure point:
try {
$result = $this->database->query($sql);
$processed = $this->processor->handle($result);
return $this->formatter->format($processed);
} catch (Exception $e) {
$this->logger->error($e);
throw new ApiException("Something went wrong", 500);
}
Three operations, one catch block. Which one failed? What state was the object in? Did $result get written before $processed blew up? The happy path reads clean. The failure mode is a guessing game.
Go returns the error alongside the result. Every failure point is a decision:
result, err := db.Query(sql)
if err != nil {
return nil, fmt.Errorf("db query: %w", err)
}
processed, err := processor.Handle(result)
if err != nil {
return nil, fmt.Errorf("processing result: %w", err)
}
formatted, err := formatter.Format(processed)
if err != nil {
return nil, fmt.Errorf("formatting: %w", err)
}
return formatted, nil
Each if err != nil forces a choice: what does failure mean here, what context should travel with the error? The %w verb wraps the original error so callers can inspect the chain with errors.Is() and errors.As(). Nothing propagates silently. Nothing gets swallowed by a global handler that logs and continues while the application sits in a half-mutated state.
The verbosity is the feature. PHP exceptions can be caught anywhere -- including by a catch-all that logs and moves on, leaving corrupted state behind. Go makes you handle each failure where it happens. Data corruption from silently swallowed exceptions becomes structurally impossible when every error is an explicit return value.
fmt.Errorf("context: %w", err)is the idiomatic wrapping pattern. Always wrap with context -- barereturn nil, errloses the call site. When debugging production errors, the wrapped chain tells you exactly which layer failed and why.
One-Method Interfaces Beat Four-Method Contracts
Define a PHP interface with four methods. Every class that implements it carries all four. A function that accepts the interface gets everything whether it uses one method or none:
interface PaymentGateway {
public function processPayment(float $amount): bool;
public function processRefund(string $txID): bool;
public function getTransactionHistory(): array;
public function updateWebhookURL(string $url): bool;
}
Write a function that only processes payments. It still receives the full PaymentGateway. To test it, you build a mock with four methods when one would do.
Go's standard library has io.Writer:
type Writer interface {
Write(p []byte) (n int, err error)
}
One method. Files, network connections, buffers, hash functions, HTTP response writers, and compression streams all satisfy it. A function accepting io.Writer works with any of them. No modification to those types, no explicit declaration -- any type with a Write([]byte) (int, error) method satisfies the interface implicitly.
PHP interfaces describe identity -- what a class is. Go interfaces describe capability -- what a value can do. Define a small interface in your package and any existing type with the right method set satisfies it without knowing your code exists:
type Processor interface {
Process(Payment) error
}
type Refunder interface {
Refund(txID string) error
}
func handlePayment(p Processor, payment Payment) error {
return p.Process(payment)
}
handlePayment accepts anything with a Process method. It knows nothing about refunds, webhooks, or transaction history. The test mock has one method, not twelve.
Large interfaces are a design smell in Go. More than three or four methods usually means you are describing a class (PHP instinct) instead of a capability (Go idiom). Split them. Compose at the call site where you need both:
type ProcessorRefunder interface {
Processor
Refunder
}
Embedding Promotes Fields Without Creating Subtypes
Build a PHP class hierarchy:
class Model { }
class User extends Model { }
class Admin extends User { }
class SuperAdmin extends Admin { }
Each layer adds behavior by extending a parent. An Admin is a User is a Model. Change the base class and every subclass breaks.
Go has no inheritance. Struct embedding promotes fields and methods from one type into another:
type Permissions struct {
CanRead bool
CanWrite bool
CanDelete bool
}
type User struct {
ID string
Email string
}
type Admin struct {
User
Permissions
AdminSince time.Time
}
admin.Email works. admin.CanDelete works. But Admin is not a User in the type system. You cannot pass an Admin where a User is expected. Embedding is field promotion, not subtyping. The distinction forces you to decide whether Admin has-a User identity (embedding) or whether a function should accept both types (interface).
PHP inheritance hierarchies model two things: shared data and shared behavior. Embedding handles shared data. Small interfaces handle shared behavior. The combination covers most inheritance use cases without the rigidity of a class tree where changing the base type cascades breakage through every descendant.
Concurrent Map Access Is a Silent Data Race
PHP's request model is share-nothing. Each request gets a fresh process. Concurrent access to shared state does not happen, and you never learn to think about it. Go services are long-running. Multiple goroutines share memory. This code passes tests and corrupts data in production:
type DataProcessor struct {
cache map[string]Result
}
func (p *DataProcessor) Process(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(idx int, it Item) {
defer wg.Done()
if cached, ok := p.cache[it.ID]; ok { // concurrent read
results[idx] = cached
return
}
result := expensiveOperation(it)
p.cache[it.ID] = result // concurrent write -- DATA RACE
results[idx] = result
}(i, item)
}
wg.Wait()
return results
}
The race detector catches it: simultaneous reads and writes to p.cache. Without the detector, behavior is undefined -- the runtime can crash, silently corrupt data, or appear to work until traffic increases.
For a write-once-read-many cache, sync.Map is the right primitive:
type DataProcessor struct {
cache sync.Map
}
func (p *DataProcessor) Process(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(idx int, it Item) {
defer wg.Done()
if cached, ok := p.cache.Load(it.ID); ok {
results[idx] = cached.(Result)
return
}
result := expensiveOperation(it)
p.cache.Store(it.ID, result)
results[idx] = result
}(i, item)
}
wg.Wait()
return results
}
sync.Map avoids lock contention for read-heavy workloads. For frequent writes across all keys, a sync.RWMutex wrapping a regular map is often better. The choice depends on the access pattern, but an unprotected map is never correct when goroutines are involved.
Run the race detector during development and CI:
go test -race ./...andgo run -race main.go. It adds CPU and memory overhead but catches races that tests cannot detect by observation alone. Ship production builds without it.
Zero Values Are Useful Starting States
Declare a PHP variable without initializing it. Access it. You get null, a warning, and a bug that propagates silently until something downstream fails.
Declare a Go variable without initializing it. It gets the zero value: "" for strings, 0 for integers, false for booleans, nil for pointers, slices, and maps. Not an error state -- a designed starting state:
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
var c Counter // mu is unlocked, count is 0
c.Increment() // works immediately -- no constructor needed
A sync.Mutex zero value is an unlocked mutex. A sync.WaitGroup zero value is a group with zero pending goroutines. A bytes.Buffer zero value is an empty buffer ready for writes. The standard library designs zero values to be useful without constructors.
Fewer constructors. Fewer "did I forget to call Init()?" bugs. Fewer nil checks. When a zero value is useful, declare and use.
Not all zero values are safe:
var m map[string]int
m["key"] = 5 // panic: assignment to entry in nil map
var sl []int
sl = append(sl, 1) // works: append handles nil slice
Maps panic on write when nil. Slices handle nil gracefully with append. The inconsistency is one of Go's genuine rough edges. Learn the difference once and you stop hitting it.
Channels Turn Pipelines Into Composable Stages
PHP concurrency options: pcntl_fork for processes, ReactPHP or Amp for event loops, or push work to a queue and let a separate worker consume it. A multi-stage concurrent pipeline in PHP requires framework machinery.
Go channels are typed, directional, and composable. A function returns a receive-only channel. The caller ranges over it without knowing how many goroutines produce values behind it:
Full pipeline: file paths to parsed log entries across CPU cores
func processLogs(files []string) <-chan ProcessedEntry {
paths := make(chan string)
go func() {
for _, path := range files {
paths <- path
}
close(paths)
}()
lines := make(chan LogLine)
var wg sync.WaitGroup
for i := 0; i < runtime.NumCPU(); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for path := range paths {
parseFile(path, lines)
}
}()
}
go func() {
wg.Wait()
close(lines)
}()
results := make(chan ProcessedEntry)
go func() {
for line := range lines {
if entry := processLine(line); entry != nil {
results <- *entry
}
}
close(results)
}()
return results
}
for entry := range processLogs(logFiles) {
fmt.Println(entry)
}
Stage 1 emits file paths. Stage 2 fans out across CPU cores, parsing files into lines. Stage 3 filters and processes lines into entries. Each stage is a goroutine or a pool of goroutines. Backpressure is handled by channel capacity. Shutdown propagates when the producer closes its channel.
The calling code sees one channel and one range loop. The parallelism, fan-out, and synchronization are encapsulated.
Channels are not always the right tool. A mutex is simpler for a cache. An errgroup handles parallel work that can fail. For pipeline stages -- producer, transformer, consumer -- channels make concurrency expressible without an event loop, a callback chain, or a framework.
Every PHP Instinct Has a Go Counterpart That Hurts
| PHP Instinct | Go Idiom | What Changes |
|---|---|---|
array() for everything | Typed structs | Runtime panics become compile errors |
| try/catch at the boundary | if err != nil at each call | Failure handling moves to the failure site |
| Large interface contracts | 1-3 method interfaces | Mocking drops from 12 methods to 1 |
| Class inheritance trees | Struct embedding + interfaces | Shared data via embedding, shared behavior via interfaces |
| Share-nothing request model | Explicit synchronization | sync.Map, sync.RWMutex, or channels required |
| Constructor initialization | Zero value design | var x T works without NewT() in many cases |
| Queue-based concurrency | Channel pipelines | Pipeline stages compose inside a single process |
PHP defers decisions to runtime and isolates you from consequences. Go requires decisions at compile time and shares consequences across goroutines. The type system, the error model, and the concurrency primitives are not obstacles. They are doing work that PHP's request lifecycle did silently.
Three years of interface{} abuse is not Go's fault. It is PHP instinct applied to a language that rewards the opposite.