Skip to content

Instantly share code, notes, and snippets.

@garyblankenship
Created July 8, 2025 15:01
Show Gist options
  • Save garyblankenship/ebc85c8338d3c312dd2df0d0c763c5b0 to your computer and use it in GitHub Desktop.
Save garyblankenship/ebc85c8338d3c312dd2df0d0c763c5b0 to your computer and use it in GitHub Desktop.
Go from a PHP Programmer #go #php

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:

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:

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:

  1. One way to do things: There's no "should I use array_map or foreach?" debate
  2. Explicit error handling: LLMs never forget to check errors
  3. Type safety: Can't accidentally pass a string where an int goes
  4. 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:

// 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:

  • "Should this be an array or an object?"
  • "Do I need to check if this key exists?"
  • "Will this type juggling bite me later?"
  • "Is this the 'Laravel way' or the 'Symfony way'?"

With Go + Claude, I just describe what I need:

  • "Parse this CSV concurrently"
  • "Add retry logic with exponential backoff"
  • "Make this safe for concurrent access"

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:

// 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):

// 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:

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:

$swiss_army_knife = [];
$swiss_army_knife[] = "indexed";
$swiss_army_knife['key'] = "associative";
$swiss_army_knife[] = ["nested", "array"];
$swiss_army_knife['objects'] = new stdClass();
// This monstrosity works fine

I tried to recreate this in Go for months:

// 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

// 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:

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:

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:

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:

// 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:

// 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:

type Writer interface {
    Write([]byte) (int, error)
}

One method. One. And it's used by:

  • Files
  • Network connections
  • Buffers
  • Hash functions
  • Compression
  • HTTP responses

The thunderbolt moment: Go interfaces aren't about what a type IS, they're about what it DOES.

// 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:

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:

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:

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:

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:

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:

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:

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:

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:

  • Dynamic thinking to static reasoning
  • Exceptions to explicit error handling
  • Inheritance to composition
  • Request isolation to concurrent access
  • Magic to explicitness

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment