Go from a PHP Programmer
The Day I Stopped Fighting Go and Started Thinking Like a Compiler
I spent three years writing Go like it was PHP with different syntax. Then I debugged one race condition that changed everything.
You know that moment when you realize you've been fundamentally wrong about something? For me, it happened at 2am, staring at a goroutine panic that shouldn't exist. I'd been treating Go like a stricter PHP—same patterns, just more typing. That night, everything clicked: Go isn't PHP with static types. It's a completely different way of thinking about code.
The Unexpected Superpower: Your AI Actually Understands Go
Before we dive into my painful journey, here's something nobody told me about Go: LLMs write it scary well. I mean genuinely excellent, production-ready code. Not the "looks right but has subtle bugs" code you get with dynamic languages—actual working Go.
Watch this. I asked Claude to write a concurrent rate limiter:
go
type RateLimiter struct {
rate int
bucket chan struct{}
stopCh chan struct{}
refillWg sync.WaitGroup
}
func NewRateLimiter(rate int) *RateLimiter {
rl := &RateLimiter{
rate: rate,
bucket: make(chan struct{}, rate),
stopCh: make(chan struct{}),
}
// Fill bucket initially
for i := 0; i < rate; i++ {
rl.bucket <- struct{}{}
}
// Start refill goroutine
rl.refillWg.Add(1)
go rl.refillLoop()
return rl
}
func (rl *RateLimiter) refillLoop() {
defer rl.refillWg.Done()
ticker := time.NewTicker(time.Second / time.Duration(rl.rate))
defer ticker.Stop()
for {
select {
case <-ticker.C:
select {
case rl.bucket <- struct{}{}:
// Token added
default:
// Bucket full
}
case <-rl.stopCh:
return
}
}
}
func (rl *RateLimiter) Allow() bool {
select {
case <-rl.bucket:
return true
default:
return false
}
}
func (rl *RateLimiter) Stop() {
close(rl.stopCh)
rl.refillWg.Wait()
}
First try. No iterations. It handles concurrent access, graceful shutdown, non-blocking checks—everything. Try getting an LLM to write thread-safe PHP code with similar guarantees. You'll get:
php
class RateLimiter {
private $tokens;
private $lastRefill;
public function allow() {
// Hope nobody calls this concurrently!
if ($this->tokens > 0) {
$this->tokens--;
return true;
}
return false;
}
}
Why LLMs Love Go (And Why You Should Too)
The magic isn't that LLMs are "better" at Go. It's that Go's explicit nature aligns perfectly with how LLMs think:
- One way to do things: There's no "should I use array_map or foreach?" debate
- Explicit error handling: LLMs never forget to check errors
- Type safety: Can't accidentally pass a string where an int goes
- No magic: What you see is what compiles
When I pair program with Claude on Go, it's like having a senior engineer who never forgets edge cases:
go
// Me: "I need to read a file and parse JSON"
// Claude immediately gives me:
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config JSON: %w", err)
}
// Validate required fields
if cfg.APIKey == "" {
return nil, errors.New("missing required field: api_key")
}
return &cfg, nil
}
Notice what it did? Error wrapping with context. Validation. Proper nil returns. This isn't boilerplate—it's exactly what production Go looks like.
The Cognitive Load Revelation
Here's the thing that actually converted me: Go + LLM = 90% less mental overhead.
In PHP, I'd spend cycles on:
With Go + Claude, I just describe what I need:
And I get working code. Not "probably working." Actually working. The compiler catches what the LLM misses (rare), and the LLM catches what I'd miss (common).
This isn't about being lazy. It's about focusing on architecture instead of syntax. When your AI pair programmer can reliably generate correct concurrent code, you're free to think about the actual problem.
The Lie We Tell Ourselves: "It's Just Syntax"
Every PHP developer learning Go gets told the same thing: "It's just stricter syntax." This is actively harmful advice. Here's what actually happens when you try to write PHP in Go:
go
// What I wrote for 6 months
data := map[string]interface{}{
"user": map[string]interface{}{
"name": "John",
"age": 30,
"tags": []interface{}{"admin", "active"},
},
}
// Trying to access nested data
userName := data["user"].(map[string]interface{})["name"].(string)
// Runtime panic when "user" doesn't exist
I was so focused on recreating PHP's flexibility that I missed Go's entire philosophy. That panic? It was Go screaming: "Stop fighting the type system and let it help you!"
The Moment Everything Changed
Here's the exact code that broke my brain (simplified from the actual disaster):
php
// My "working" PHP code
function processUser($data) {
$user = $data['user'] ?? null;
if ($user && isset($user['name'])) {
return strtoupper($user['name']);
}
return 'ANONYMOUS';
}
// Works with anything
processUser(['user' => ['name' => 'john']]); // "JOHN"
processUser(['user' => null]); // "ANONYMOUS"
processUser([]); // "ANONYMOUS"
processUser("not even an array"); // "ANONYMOUS" with warning
My Go translation:
go
func processUser(data map[string]interface{}) string {
if user, ok := data["user"].(map[string]interface{}); ok {
if name, ok := user["name"].(string); ok {
return strings.ToUpper(name)
}
}
return "ANONYMOUS"
}
It worked! Until production. Until concurrent requests. Until the race detector lit up like Christmas. Until I realized I was passing the same map to multiple goroutines and mutating it elsewhere.
PHP had trained me to think "if it runs, it works." Go demands you think "if it compiles, it's correct."
The Great Unlearning: Arrays Aren't Arrays
This one hurt. In PHP, arrays are everything:
php
$swissarmyknife = [];
$swissarmyknife[] = "indexed";
$swissarmyknife['key'] = "associative";
$swissarmyknife[] = ["nested", "array"];
$swissarmyknife['objects'] = new stdClass();
// This monstrosity works fine
I tried to recreate this in Go for months:
go
// My shameful attempt
type PHPArray struct {
indexed []interface{}
mapped map[string]interface{}
mu sync.RWMutex // Because concurrent access
}
// 200 lines of methods to make it "just work"
Then a colleague reviewed my code and asked one question that shattered my worldview:
"Why are you storing heterogeneous data in the same structure?"
I didn't have an answer. In PHP, we do it because we can. In Go, the question is why would you?
The Revelation: Separate Concerns, Gain Power
go
// What I should have written from day one
type UserData struct {
Users []User
Metadata map[string]string
Tags []string
}
// Compile-time guarantees!
func processUsers(data UserData) {
for _, user := range data.Users {
// No type assertions, no runtime checks
fmt.Println(user.Name) // It just works
}
}
The compiler became my pair programmer. Every "cannot use X as Y" error was Go saying "you're about to shoot yourself in the foot."
Error Handling: The Pattern That Explains Everything
I hated Go's error handling. Hated it. Coming from PHP's exceptions, this felt like stone age programming:
php
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);
}
Clean! Elegant! The happy path is clear!
My first Go attempt:
go
result, err := db.Query(sql)
if err != nil {
return nil, err
}
processed, err := processor.Handle(result)
if err != nil {
return nil, err
}
formatted, err := formatter.Format(processed)
if err != nil {
return nil, err
}
return formatted, nil
I wanted to flip a table. Then, during that 2am debugging session, I found a bug that had been silently corrupting data for weeks. In PHP, an exception in a rarely-hit code path had been caught by a global handler, logged, and ignored. The system kept running with corrupted state.
If I'd written it in Go:
go
data, err := corruptibleOperation()
if err != nil {
// CAN'T PROCEED WITH INVALID DATA
return fmt.Errorf("operation failed, rolling back: %w", err)
}
// 'data' is GUARANTEED to be valid here
The verbosity isn't a bug—it's the entire point. Every if err != nil
is a conscious decision about failure. No silent corruption. No unexpected states.
The Mental Model Shift
PHP exceptions teach us to think in terms of "normal flow" and "error flow." Go eliminates this distinction:
go
// This isn't "error handling"
// This is "control flow"
user, err := fetchUser(id)
if err != nil {
// This is just another code path
return handleMissingUser(id)
}
// Continue with valid user
Once I stopped seeing errors as exceptions and started seeing them as values, concurrent programming became obvious. You can't throw exceptions across goroutines—but you can send errors through channels.
The Interface Epiphany: It's Not About Types
I spent months trying to recreate PHP interfaces in Go:
go
// WRONG: Thinking in PHP
type PaymentGateway interface {
ProcessPayment(amount float64) error
ProcessRefund(txID string) error
GetTransactionHistory() []Transaction
UpdateWebhookURL(url string) error
// 15 more methods...
}
This is PHP thinking: "A PaymentGateway must do all these things."
Then I saw this in the standard library:
go
type Writer interface {
Write([]byte) (int, error)
}
One method. One. And it's used by:
The thunderbolt moment: Go interfaces aren't about what a type IS, they're about what it DOES.
go
// The Go way: Interface segregation on steroids
type Processor interface {
Process(Payment) error
}
type Refunder interface {
Refund(txID string) error
}
// Use only what you need
func handlePayment(p Processor, payment Payment) error {
return p.Process(payment)
}
Any type with a Process
method satisfies Processor
. No inheritance. No explicit declarations. Just behavior.
Composition: The Superpower Hidden in Plain Sight
PHP's inheritance hierarchy:
php
class Model { }
class User extends Model { }
class Admin extends User { }
class SuperAdmin extends Admin { }
// The taxonomy of sadness
I tried to recreate this in Go using embedding. Failed spectacularly. Then discovered I was thinking backwards:
go
type Permissions struct {
CanRead bool
CanWrite bool
CanDelete bool
}
type User struct {
ID string
Email string
}
type Admin struct {
User // HAS a user identity
Permissions // HAS permissions
AdminSince time.Time
}
// admin.Email works!
// admin.CanDelete works!
// But Admin isn't "a type of" User
This shattered my OOP brain. Admin
isn't a special kind of User
. It's a composition of capabilities. Once you see it, you can't unsee it: most inheritance hierarchies are just shared struct fields with extra steps.
The Concurrency Catastrophe (That Taught Me Everything)
Here's the PHP code that nearly ended my Go career:
php
class DataProcessor {
private $cache = [];
public function process($items) {
foreach ($items as $item) {
if (!isset($this->cache[$item->id])) {
$this->cache[$item->id] = $this->expensiveOperation($item);
}
yield $this->cache[$item->id];
}
}
}
My "clever" Go translation:
go
type DataProcessor struct {
cache map[string]Result
}
func (p *DataProcessor) Process(items []Item) []Result {
results := make([]Result, len(items))
// "I'll just process in parallel for SPEED!"
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(idx int, it Item) {
defer wg.Done()
// Check cache
if cached, ok := p.cache[it.ID]; ok {
results[idx] = cached
return
}
// Not in cache, compute and store
result := expensiveOperation(it)
p.cache[it.ID] = result // 💥 DATA RACE
results[idx] = result
}(i, item)
}
wg.Wait()
return results
}
Worked great in testing. Exploded in production. The race detector output was a masterclass in concurrent access violations.
The fix taught me Go's concurrency philosophy:
go
type DataProcessor struct {
cache sync.Map // NOT a map with a mutex
}
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()
// LoadOrStore is atomic!
cached, loaded := p.cache.LoadOrStore(it.ID, nil)
if loaded && cached != nil {
results[idx] = cached.(Result)
return
}
result := expensiveOperation(it)
p.cache.Store(it.ID, result)
results[idx] = result
}(i, item)
}
wg.Wait()
return results
}
But here's the real lesson: PHP's request model protected me from myself. Each request gets fresh state. No shared memory. No races. Go forces you to think about concurrent access from day one.
The Final Revelation: Zero Values Are a Design Philosophy
This broke my brain in the best way:
go
var s string // ""
var i int // 0
var b bool // false
var p *User // nil
var m map[string]int // nil
var sl []int // nil
// BUT...
m["key"] = 5 // Panic! nil map
sl = append(sl, 1) // Works! append handles nil
In PHP, uninitialized variables are a minefield. In Go, zero values are useful:
go
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
c.count++ // Works even without explicit initialization
c.mu.Unlock()
}
// Zero Counter is a valid Counter!
var c Counter
c.Increment() // Just works
This isn't an accident. It's a philosophy: useful zero values eliminate entire categories of bugs.
The Code That Changed My Mind Forever
Want to see the exact moment I became a Go convert? This concurrent pipeline that would be a nightmare in PHP:
go
func processLogs(files []string) <-chan ProcessedEntry {
// Stage 1: Read files
paths := make(chan string)
go func() {
for _, path := range files {
paths <- path
}
close(paths)
}()
// Stage 2: Parse lines (fan-out)
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)
}()
// Stage 3: Process entries
results := make(chan ProcessedEntry)
go func() {
for line := range lines {
if entry := processLine(line); entry != nil {
results <- *entry
}
}
close(results)
}()
return results
}
// Usage is beautiful
for entry := range processLogs(logFiles) {
fmt.Println(entry)
}
Try writing this in PHP. I'll wait. The channels ensure proper synchronization. The goroutines handle parallelism. The types guarantee correctness. It's not just different syntax—it's a different universe of possibilities.
The Truth Nobody Tells You
Here's what three years of fighting Go taught me: PHP and Go solve different problems. PHP excels at request/response cycles with shared-nothing architecture. Go excels at long-running services with concurrent operations.
Trying to write PHP in Go is like using a hammer on screws. It might work, but you're missing the point.
The real migration isn't from PHP syntax to Go syntax. It's from:
Stop trying to make Go feel like PHP. Let it teach you a completely different way to think about code. That race condition at 2am? It wasn't a bug—it was Go trying to show me a better way.
Now if you'll excuse me, I need to refactor three years of interface{}
abuse into proper types.