All articles
12 September 2026
8 min read

Learning Go as a PHP developer: five wrong assumptions

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

Two wrenches on the same nut; the blue one grips, the grey one sits a fraction off.

When a PHP team writes its first Go service, it runs after two weeks. It still runs after six months, and by then you can see which language the team came from: in five places, and always the same five.

That is not about ability. It is the normal result of reading a new language with the vocabulary of the old one. Go looks like a simple language, which is why people skip the places where it means something different from what it looks like.

This article describes five of them. It is written for teams running a service next to an existing PHP system, not for language comparisons.

Wrong assumption 1: errors are exceptions

In PHP you throw an exception and catch it high up. The control flow in between stays clean, and that is convenient. Go has no exceptions, and the first reflex is to read that as clumsiness.

The pattern that results looks like this:

// Brought over from PHP: pass the error on without adding anything.
if err != nil {
	return err
}

That translates, it works, and it strips the error of everything on the way up that would make it useful. What ends up in the log is connection refused, and nobody knows which of the nine connections is meant.

The language means something else here: the error is a return value, and at every level it gets the context only that level knows.

// Every level adds what only it knows. %w keeps the original error, so
// errors.Is and errors.As can still find it.
func (r *Reader) Customer(ctx context.Context, id int) (Customer, error) {
	row, err := r.db.QueryRowContext(ctx, query, id).Scan(...)
	if err != nil {
		return Customer{}, fmt.Errorf("read customer %d: %w", id, err)
	}
	...
}

// At the top you then get:
// customer import: read customer 4711: dial tcp 10.0.2.7:3306: connection refused

The second part of this is the distinction the exception class handles in PHP: an expected failure and an unexpected one are not the same thing. In Go that happens through values you can compare.

var ErrNotFound = errors.New("not found")

// At the caller:
if errors.Is(err, ErrNotFound) {
	// Expected: 404 rather than 500, no page to the on-call rota.
	return respondNotFound(w)
}

The rule that follows, and the one I find most useful: a bare return err is only right when the function knows nothing the caller does not already know. That is rare.

Wrong assumption 2: concurrency is free

PHP developers usually know concurrency as something expensive: a second process, a queue, a worker. In Go a concurrent operation costs one line, and that is exactly what leads to the second wrong assumption.

// Looks harmless and is the most common bug in a first service.
for _, id := range ids {
	go process(id)
}

Three things are missing from that, and each one on its own makes the service unreliable. Nobody waits for the result, so the program may end first. Nobody bounds the number, so ten thousand entries start ten thousand operations against a database with twenty connections. And nobody can cancel when the request is long gone.

The version that holds is barely longer:

func processAll(ctx context.Context, ids []int) error {
	group, ctx := errgroup.WithContext(ctx)
	// Bounded, and bounded at the scarcest resource: here the database
	// connections, not the number of cores.
	group.SetLimit(8)

	for _, id := range ids {
		group.Go(func() error {
			return process(ctx, id)
		})
	}

	// Waits, cancels the rest on the first error and returns it.
	return group.Wait()
}

The context is the part with no equivalent in PHP and therefore the one most likely to be left out. It carries the cancellation signal through the whole call tree: when the caller gives up, everything below stops. Without it, a service keeps working on requests nobody is reading any more, and that only shows under load.

Wrong assumption 3: packages are namespaces

In PHP a namespace is an organizing idea: it says where something lives, and nothing else. A team bringing that along builds a directory structure by layer in Go, because that is how the PHP project looked.

worse/                            better/
├── models/                       ├── billing/
├── services/                     ├── shipping/
├── repositories/                 └── catalog/
└── controllers/

In Go a package is a boundary. What is lowercase is invisible outside it, and that is effective protection which the left structure gives away: if all models sit in one package, everything is visible everywhere.

Two consequences show up in a first service.

Import cycles are a compile error, not a warning. That feels strict at first and is the most useful constraint in the language: where a cycle appears, the split is wrong, and you notice immediately rather than after two years.

internal/ is not a convention, it is a rule. What sits under it cannot be imported from outside the module. For a service that may later publish parts of itself, that is the simplest way to keep the public surface small.

Wrong assumption 4: interfaces belong to the implementation

The PHP reflex: interface first, then the class, both in the same directory, and the class says implements. Go has no implements, and that is not a simplification, it is a different allocation.

An interface belongs where it is needed, not where it is satisfied. The consumer describes what it needs; whoever satisfies it knows nothing about it.

// In the package that does shipping. Describes its own need, not the
// feature set of the customer service.
type CustomerSource interface {
	Customer(ctx context.Context, id int) (Customer, error)
}

type Shipping struct {
	customers CustomerSource
}

The practical return shows up in testing: the fake has one method, not fourteen. And the rule behind it is short enough to remember: the larger the interface, the more likely it sits in the wrong place. A one-method interface is normal in Go, not undersized.

How a Go service attaches to a PHP system without adopting its vocabulary is covered in Building an anti-corruption layer between Go and PHP.

Wrong assumption 5: a framework is missing

The fifth is the most persistent, because it sounds like productivity. It goes: to get started we pick a framework, then we have to build less ourselves.

For a service of this kind, the standard library covers almost everything: HTTP server with pattern routing, JSON, database access, structured logging, tests. What is missing is one or two libraries, not a framework.

// Since Go 1.22 the standard library handles routes with method and
// parameters. A framework only for that is a dependency that has a say
// in every future upgrade.
mux := http.NewServeMux()
mux.HandleFunc("GET /customers/{id}", h.customer)
mux.HandleFunc("POST /customers", h.create)

Behind that sits the property that separates Go most clearly from PHP frameworks: there is no magic. No container guessing dependencies, no annotations wiring something up at runtime. Dependencies are passed, visibly, usually in one function that assembles the service.

On day one that feels like more work. After six months it is the reason you can read the service: everything that happens is written down somewhere as a call.

What comes next with your first service of your own is covered in Configuration and secrets for Go services.

What transfers well from PHP

So that the impression does not form that everything is different: three things an experienced PHP team brings along transfer straight over.

A feel for data access. Anybody who knows how a query inside a loop finishes off an application knows it in Go too. The language changes nothing about that, and the most common performance bug is the same in both.

Dealing with legacy. The experience that a data model outlives fifteen years is worth more in a new service than any language knowledge. It leads to taking the boundary to the legacy system seriously rather than adopting its structure.

Operational experience. Anybody who has read a log at four in the morning writes better log output. That is independent of the language and more important than the language.

How a Go service actually gets placed next to an existing PHP system is covered in its own article: Golang in a PHP world. And if you are looking for somebody to do that with your team: the role page says how I work.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.