Modules vs Composer

Station 0.4 12 min Interchange: composer.json & composer require → go.mod

Composer is one of the best things that ever happened to PHP. Before it, "dependency management" meant a lib/ folder and a prayer. So the good news: Go's equivalent, modules, will feel familiar. The interesting part is the handful of places where Go made a very different call.

go.mod is your composer.json

Every Go project has a go.mod at its root. It says three things: what this module is called, which Go version it targets, and what it depends on.

module github.com/you/departures

go 1.26

require github.com/spf13/cobra v1.9.1

That first line, the module path, matters more than a Packagist name does. It's the prefix for every import in your project, and for public code it's usually where the code lives: github.com/you/departures. There's no central registry like Packagist. Go fetches modules through a public proxy (proxy.golang.org) using that path.

The everyday commands

You'll rarely edit go.mod by hand:

  • Start a project: go mod init github.com/you/departures is your composer init. It writes a three-line go.mod and nothing else. No questionnaire. (That's the command from 0.2, now with a name that means something.)
  • Add a dependency: go get github.com/google/uuid, or just import it in your code and run go mod tidy.
  • Install: there's no composer install step. go build and go test download whatever's missing into a shared module cache (~/go/pkg/mod), not into your project.
  • Clean up: go mod tidy adds anything you import but haven't required, and removes anything you no longer use. Run it whenever you change imports. It's the composer dump-autoload you'll actually remember to run.

Where's the lock file?

There isn't one, and that's deliberate.

Composer lets you write ranges like ^7.9, resolves them to the newest matching versions, and records the result in composer.lock. Without the lock file, two installs a week apart could pull different code.

Go doesn't do ranges. go.mod lists one version per dependency, and Go uses minimal version selection: for each module it picks the highest version that anything in your build explicitly asks for, and never a newer one just because it exists. So the same go.mod always builds the same code, today or in two years. The lock file's job is done by go.mod itself.

composer update, Fri 16:55Updating dependencies (including require-dev)…
Fri 16:58147 packages updated. Tests: mostly green.
Mon 09:00"Did anyone change anything?"
go build, any daySame versions as yesterday. Go only upgrades when you ask.
Delays expected on the Friday afternoon service.

Mind the gap

go.sum is not composer.lock. It doesn't choose versions. It stores a checksum for every module your build uses, so Go can prove the code it downloaded is byte-for-byte what everyone else got. Commit both go.mod and go.sum, and never delete go.sum to "fix" something.

Imports instead of an autoloader

PHP's autoloader maps class names to files: PSR-4 turns App\Stations\Station into src/Stations/Station.php. Go has no autoloader because it doesn't need one. An import path names a directory, and everything in that directory is one package:

  • "fmt", "net/http": standard library. No domain, nothing to download.
  • "github.com/you/departures/internal/stations": your module path plus a directory in your project. (internal is a special name: Go won't let any other module import what's under it, so it's the place for code that's nobody else's business.)
  • "github.com/google/uuid": someone else's module.

You'll need fewer packages

A fresh Laravel install pulls dozens of packages into vendor/. A typical Go service has a handful, because the standard library already covers HTTP servers and clients, JSON, testing, crypto, templating, logging and more. There's a Go proverb about it: a little copying is better than a little dependency. Before you go get something, check whether the standard library already does it. The exercise is built around that habit.

Recap

  • go.mod declares your module path, Go version and dependencies. go get adds them, go mod tidy keeps them honest.
  • No version ranges and no lock file: minimal version selection makes builds reproducible. go.sum holds checksums, so commit it.
  • Import paths name directories. The standard library has no domain, and it covers a lot.

1The manifest

PHPcomposer.json
{
    "name": "you/departures",
    "require": {
        "php": "^8.3",
        "guzzlehttp/guzzle": "^7.9",
        "symfony/console": "^7.2"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    }
}
GOgo.mod
module github.com/you/departures

go 1.26

require (
	github.com/spf13/cobra v1.9.1 // like symfony/console
	golang.org/x/sync v0.11.0
)

Mind the gap

No ^ ranges in go.mod: each line is the minimum version you need, and Go won't quietly pick a newer one. And Guzzle has no Go equivalent to install, because net/http is in the standard library.

2The everyday commands

PHPcomposer.sh
composer init                        # start a project
composer require guzzlehttp/guzzle   # add a dependency
composer install                     # install from composer.lock
composer update                      # newest versions within ^ranges
composer remove guzzlehttp/guzzle    # drop it
composer dump-autoload               # rebuild the class map
GOgomod.sh
go mod init github.com/you/departures # start a project
go get github.com/google/uuid         # add a dependency
go build                              # downloads anything missing
go get -u ./...                       # upgrade dependencies, on purpose
go mod tidy                           # drop unused, add missing
# no autoloader: import paths are the map

3Autoloading vs import paths

PHPautoload.php
<?php
namespace App\Board;

use App\Stations\Station;   // PSR-4: src/Stations/Station.php
use GuzzleHttp\Client;      // vendor/guzzlehttp/guzzle/src/Client.php
GOimports.go
package board

import (
	"net/http" // standard library: no domain

	"github.com/google/uuid"                          // someone else's module
	"github.com/you/departures/internal/stations"    // your module + a directory
)

Mind the gap

An import path names a directory (a package), not a class. Every file in that directory is part of the same package.