Your First Month in Go as a PHP Developer: The Honest Diary

php dev.to

You decided to learn Go. You have shipped Laravel or Symfony for years. You can write Eloquent in your sleep, you know which container binding fires when, and the difference between a PHP-FPM and an Octane request lifecycle is muscle memory.

None of that helps you for the first three days. Not in the way you expect.

Treat this as a map for the four weeks ahead, ordered by week, with the specific moments where the language stops fighting you.

Week 1 — The bewilderment

The first week is mostly about looking at your screen and wondering where everything went.

Day 1: where is the framework

You install Go. You type go run main.go. It works. There is no composer install, no .env file, no php artisan serve, no vendor/ directory full of code you did not write. You have one file. The file runs.

This sounds like a relief. It is not. By the end of day one you will have written something like this:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hello")
    })
    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

And you will sit there wondering where the router config is, where the middleware list is, where the controller resolution lives. The honest answer is: nowhere, yet. You write those layers yourself, or you bring them in as small libraries when you need them. After ten years of "the framework calls your code," Go asks you to write the calling code yourself.

Day 3: you reach for Tinker

By day three you want to inspect something. You wrote a config parser and you want to poke at the result. In Laravel you would type php artisan tinker, paste your code, and read the output.

Go has no REPL. You will Google "go REPL" and find gore, yaegi, and a handful of half-maintained tools. None will feel right. The pattern that wins, which you will not believe on day three, is to write a test.

func TestParseConfig(t *testing.T) {
    cfg, err := ParseConfig("./testdata/sample.toml")
    if err != nil {
        t.Fatal(err)
    }
    t.Logf("%+v", cfg)
}
Enter fullscreen mode Exit fullscreen mode

go test -run TestParseConfig -v ./... and you have your inspection. Heavy on day three. By day twenty you write tests this way without thinking, and you have a permanent record of what you poked at instead of a Tinker session that vanished.

Day 5: errors are values, and that hurts

Every function that can fail returns two things: the value and an error. You will spend day five typing if err != nil { return err } more times than you have ever typed anything in your life.

file, err := os.Open(path)
if err != nil {
    return nil, err
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return nil, err
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
    return nil, err
}
Enter fullscreen mode Exit fullscreen mode

In PHP you would write three lines and let an exception bubble. Here you write twelve, and the compiler will let you ignore the error if you assign it to _. Do not. Ignored errors are a well-known source of silent bugs in Go. That is why errcheck and staticcheck exist as standard lints.

By the end of week one you will read this style as a feature. The error path is visible. You can grep it. There is no try/catch four files away that will catch your RuntimeException and log it to a forgotten file. On day five it just hurts.

Week 2 — The small wins

Week two is when the language starts giving you something back.

Day 8: structs and tags click

You have been writing JSON parsing in Laravel for years. json_decode($body, true) and then $data['user']['email'] and pray nobody renames a field. In Go you write a struct.

type CreateUserRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
    Role     string `json:"role,omitempty"`
}

func handleCreate(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    // req.Email, req.Password, req.Role are typed
}
Enter fullscreen mode Exit fullscreen mode

The backtick strings after each field are struct tags. The compiler ignores them; the encoding/json package reads them at runtime. The same tags are used by sqlx, by validators, by ORMs, by anything that needs metadata about the struct.

Day eight is when this single pattern replaces a stack you used to assemble in Laravel: form requests, validators, ORM column mappings, API resources. One struct, four tags, every layer reads the same source of truth.

Day 10: you stop missing Tinker

You have written maybe twenty test files by now. When you want to inspect a function you write a test. When you want to try a new package you write a test. When you want to see what an HTTP response looks like you write a test that uses httptest.NewRecorder and t.Logf to print the body.

func TestUserHandler_Create(t *testing.T) {
    body := strings.NewReader(`{"email":"a@b.com","password":"x"}`)
    req := httptest.NewRequest(http.MethodPost, "/users", body)
    w := httptest.NewRecorder()

    handleCreate(w, req)

    t.Logf("status=%d body=%s", w.Code, w.Body.String())
}
Enter fullscreen mode Exit fullscreen mode

Run with go test -run TestUserHandler_Create -v and see the result in milliseconds. No web server. No Postman. No tinker. The thing that felt heavy on day three is now the fastest feedback loop you have ever had, and it leaves a test behind you can rerun in CI.

Day 12: goroutines that do not leak

You write your first goroutine. Ten HTTP requests in parallel. They come back. You feel like a wizard.

var wg sync.WaitGroup
results := make([]string, len(urls))

for i, u := range urls {
    wg.Add(1)
    go func(i int, u string) {
        defer wg.Done()
        results[i] = fetch(u)
    }(i, u)
}
wg.Wait()
Enter fullscreen mode Exit fullscreen mode

Then you read your first piece on goroutine leaks and the hat fits worse than you thought. You learn about context.Context with deadlines, about errgroup for the case where one of the ten calls fails, about the closure-over-loop-variable trap (less of a trap since Go 1.22, but worth knowing for older code).

By day twelve you have rewritten that fan-out three times and you know which goroutines have a way to finish. The lesson lands: a goroutine you spawn without a cancellation path is a memory leak with a delay timer.

Week 3 — It clicks

Week three is when the language stops feeling like a language you are translating into and starts feeling like the one you think in.

Day 16: composition replaces inheritance

In Laravel your controllers extend a base controller. Models extend Eloquent. Jobs implement ShouldQueue. You inherit, override, call parent::method().

Go has no inheritance. None. You compose by embedding.

type Logger struct{}

func (l Logger) Info(msg string) { /* ... */ }

type UserService struct {
    Logger
    db *sql.DB
}

// usage
svc := UserService{db: db}
svc.Info("created user")
Enter fullscreen mode Exit fullscreen mode

The Logger field with no name is an embedded type. Methods on Logger become methods on UserService. There is no parent class — the compiler does field promotion, and every step is visible in the struct definition.

By day sixteen you stop trying to recreate inheritance. Your "base controller" turns into a struct with a logger and a database pool, and the eight controllers that "extend" it just embed it.

Day 18: interfaces, the small ones

In Java, C#, or PHP, interfaces usually live near the implementation and list every method the type might want. In Go, interfaces are tiny. Often one method. They live near the consumer, not the producer.

type Storer interface {
    Save(ctx context.Context, u User) error
}

func RegisterUser(ctx context.Context, s Storer, u User) error {
    return s.Save(ctx, u)
}
Enter fullscreen mode Exit fullscreen mode

RegisterUser accepts anything with a Save method. A real Postgres store satisfies it. A test fake satisfies it. None declare implements Storer. The compiler checks the shape.

This is when "accept interfaces, return structs" makes sense. You do not need a 30-method UserRepository interface to test one function. You declare the one method you need where you need it. Your tests get smaller. Your dependencies become visible.

Day 20: you forget why you needed Eloquent

You have written maybe eight queries with database/sql or pgx by now, and one or two with sqlc generating typed query functions. The queries are SQL. They live in .sql files. The Go code that calls them is generated.

-- name: GetActiveUsers :many
SELECT id, email, created_at
FROM users
WHERE active = true
ORDER BY created_at DESC
LIMIT $1;
Enter fullscreen mode Exit fullscreen mode
users, err := q.GetActiveUsers(ctx, 20)
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

users is a slice of a typed struct. The IDE autocompletes every field. No ->where, no ->with, no chain of method calls that secretly produces a SQL string you cannot grep.

On day twenty you will realise you have not thought about N+1 in a week. You have not had whereHas produce a join you did not expect. You have not run ddd($query->toSql()) to figure out what Eloquent translated. The translation does not happen — you wrote the SQL.

This is when most PHP developers stop missing Eloquent. Some keep missing the speed of writing CRUD with it — that part is fair. Nobody misses the surprises.

Week 4 — You start critiquing PHP

Week four is the strange one. You are still a PHP developer. You still have a Laravel app paying your bills. But you start noticing things you used to accept.

Day 23: you notice the boot cost

You run time ./myapp and Go prints "ready" in tens of milliseconds. You run php artisan serve and it takes most of a second before it accepts a request. (Numbers will vary by machine; the order of magnitude is the point.) Every request reboots a process unless you are on Octane or RoadRunner. You have known this for years. On day twenty-three it starts to grate.

You realise the reason your Laravel job queue exists is partly because PHP cannot do real concurrency in a single process without bolt-ons. Redis is your in-memory cache because PHP-FPM dies between requests. Background work lives in a separate worker because the request lifecycle was never long enough to do it in-process.

In Go, you do not need a queue for "send a welcome email after user creation." You spawn a goroutine with a 30-second context and you are done.

func (s *UserService) Create(ctx context.Context, u User) error {
    if err := s.repo.Save(ctx, u); err != nil {
        return err
    }
    go func() {
        ctx, cancel := context.WithTimeout(
            context.Background(), 30*time.Second)
        defer cancel()
        s.mailer.SendWelcome(ctx, u.Email)
    }()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

You will still want a real queue for retryable, durable, observable work. That has not changed. But the bar for "spin up a queue worker" is now higher than it was a month ago, because the runtime can do work in the background on its own.

Day 25: the binary is the deploy

You type go build -o myapp ./cmd/myapp. You get a single file. You scp it to a server. You run it. It serves traffic.

There is no PHP version to align. No extensions. No composer install on the server. No php-fpm.conf. No pm.max_children to size against your RAM. One binary. One systemd unit. Done.

You will go back to your Laravel Dockerfile and look at it differently. The one with the long install chain: PHP 8.4, opcache, a precise set of extensions, composer install --no-dev, php artisan optimize, FPM with a tuned worker count. All of that exists because PHP needs a runtime per request. Go has a runtime per process.

This is not a reason to throw away your Laravel app. It is a reason to understand why a Go service typically sits in the tens of MB while a PHP-FPM pool can run into the hundreds, depending on pm.max_children. (Illustrative ranges, not benchmarks.)

Day 28: PHP 8.4 looks closer than you remembered

By the end of the month you will have a fairer view of PHP than you started with. Modern PHP, through 8.4, has typed properties, readonly classes, asymmetric visibility, real enums, fibers, named arguments, and declare(strict_types=1). Octane gives you a long-lived process. Modern PHP looks more like Go than you wanted to admit.

What it does not give you is the toolchain. gofmt is not negotiable. go vet runs in your editor. go test -race finds data races. go build produces a binary. Every Go service uses the same formatter, the same test runner, the same import system. You stop arguing about Pest vs PHPUnit, PSR-12 vs custom style, Rector vs Psalm. Go shipped with the stack.

Day 30: you write your second Go service

By day thirty, you have shipped one toy service and one half-real one. You start a third, and this is the one where you do not Google "Go HTTP server tutorial" once. You type out a main with slog, an http.Server with a real ReadHeaderTimeout, a goroutine for ListenAndServe, and a signal.Notify block that traps SIGTERM and calls srv.Shutdown with a 10-second context.

sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig

ctx, cancel := context.WithTimeout(
    context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
Enter fullscreen mode Exit fullscreen mode

You did not copy this. You wrote it because the pieces are now in your head. None of this was in your head a month ago.

That is what a month of Go does to a PHP developer. You are not a Go expert — the GC, escape analysis, channel patterns, generics are all still ahead. But the language has stopped being a foreign object. It is the second language you can write a service in without permission.

What to do with this map

If you are about to start the journey, three things to keep at hand:

  • Write tests instead of reaching for Tinker. The friction goes away faster than you expect, and the tests are still there next month.
  • Treat every error. _ = is a smell. if err != nil { return err } is the language asking you to be explicit.
  • Spawn no goroutine without a way to stop it. Context, channel, or WaitGroup. Pick one and use it.

The first month is the steepest part. By day thirty-one, the curve flattens. By day sixty, you start feeling restricted in PHP.

If this was useful

Thinking in Go is the 2-book series written for developers coming from a framework-heavy background. The Complete Guide to Go Programming fills in the language and stdlib your first month surfaced but did not explain. Hexagonal Architecture in Go is the follow-up for when project layout becomes its own problem on service number two.

Together they are the two books a PHP developer needs to stop translating Laravel patterns and start writing Go that reads like Go.

Source: dev.to

arrow_back Back to Tutorials