Go for PHP Developers Moving Away From Framework Habits
What PHP developers must unlearn in Go: shared-nothing requests, associative arrays, exceptions and magic, with runnable PHP 8.4 and Go pairs.

PHP developers are some of the best-prepared people to learn Go and some of the most surprised by it. You already know HTTP, databases, sessions, queues and how a web application is put together, and Laravel or Symfony taught you structure. What PHP does not prepare you for is the runtime: in PHP, every request is a fresh universe that ends when the response does, and in Go your program is a server that stays up for weeks. That single difference changes where state lives, how you write handlers and how bugs happen. This guide translates the habits with runnable pairs: PHP 8.4 and Go 1.25, outputs under each. It sits in the Go basics path, and this site's own back end is Laravel, which is a good reminder that the two languages work together. Each Go block is a full program that runs with go run . in a module.
Tested with Go 1.25.6 and PHP 8.4, macOS on Apple Silicon (arm64)
The process model, first
In PHP-FPM or any classic setup, the request enters, the framework boots, your code runs, the response goes out and everything is freed. A variable set in one request does not exist in the next. This script, run three times as three requests, shows it:
<?php
static $count = 0;
$count++;
echo $count, "\n";1It prints 1 and exits, and a second execution would print 1 again, because PHP forgot. The bash loop that runs it three times is the nearest thing to three requests:
printf '<?php\nstatic $count = 0;\n$count++;\necho $count, "\\n";\n' > c.php
for i in 1 2 3; do php c.php; done1
1
1Now the Go version. A package-level variable lives as long as the process, and a handler that increments it sees the result of every earlier request:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
var hits int
func handler(w http.ResponseWriter, r *http.Request) {
hits++
fmt.Fprintln(w, hits)
}
func main() {
for range 3 {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest("GET", "/", nil))
fmt.Print(rec.Body.String())
}
}1
2
3It prints 1, 2 and 3. In a real server, those three calls would come from different clients on different goroutines at the same time, and that is the part PHP never taught you: hits++ from several goroutines is a data race. Run the same code with simultaneous callers under the race detector and it says so:
package main
import (
"fmt"
"net/http/httptest"
"sync"
)
var hits int
func main() {
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
rec := httptest.NewRecorder()
hits++
fmt.Fprintln(rec, hits)
}()
}
wg.Wait()
}==================
WARNING: DATA RACE
Read at 0x0001025a1158 by goroutine 12:
main.main.func1()
example.test/lab/main.go:18 +0x150
Previous write at 0x0001025a1158 by goroutine 27:
main.main.func1()
example.test/lab/main.go:18 +0x168
...The report names the two lines that wrote and read the variable without synchronization. The fix is an atomic.Int64 for a counter, or a sync.Mutex around anything bigger, as mutexes in Go and the memory model explain. The habit to adopt is simple: a global variable in Go is shared state across every request, so treat it the way you would treat a static property in a long-running PHP daemon, such as Swoole or RoadRunner, and put a lock on it or avoid it. Per-request data lives in the handler's local variables or in the request's context. Context cancellation in web services develops that.
Arrays become three types
PHP's array is a list, a dictionary and a record at once. Go makes you choose, and the choice documents your intent:
<?php
$users = [["name" => "ada", "age" => 36, "active" => true], ["name" => "kid", "age" => 9, "active" => true],
["name" => "grace", "age" => 45, "active" => false], ["name" => "linus", "age" => 28, "active" => true]];
$adults = array_filter($users, fn($u) => $u["active"] && $u["age"] >= 18);
usort($adults, fn($a, $b) => $b["age"] <=> $a["age"]);
echo json_encode(array_column($adults, "name")), "\n";["ada","linus"]package main
import (
"cmp"
"encoding/json"
"fmt"
"slices"
)
type User struct {
Name string
Age int
Active bool
}
func main() {
users := []User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}}
var adults []User
for _, u := range users {
if u.Active && u.Age >= 18 {
adults = append(adults, u)
}
}
slices.SortFunc(adults, func(a, b User) int { return cmp.Compare(b.Age, a.Age) })
names := make([]string, 0, len(adults))
for _, u := range adults {
names = append(names, u.Name)
}
out, _ := json.Marshal(names)
fmt.Println(string(out))
}["ada","linus"]Both print ["ada","linus"]. The PHP array of associative arrays became a slice of structs: the shape, name, age, active, is declared once and checked by the compiler, where PHP would accept $u["agee"] and give you a warning at run time and null. array_filter and usort became a loop and slices.SortFunc. array_filter preserves keys, a PHP quirk that the loop does not have, and usort takes a spaceship comparison where Go's returns negative, zero or positive from cmp.Compare, the same idea. For keyed data whose keys are not known in advance, use a map[string]T, and mind that a Go map has no defined order, as map iteration order shows, where PHP arrays remember insertion order. Slices, maps and structs are the three articles behind this section.
Null, coalescing and loose comparison
PHP's ?? and its loose == are daily tools and both have no Go equivalent, on purpose. Here is the PHP behavior, with its famous comparisons:
<?php
$data = json_decode('{"user": {"name": "ada"}}', true);
echo $data["user"]["address"]["city"] ?? "unknown", "\n";
echo var_export("10" == "1e1", true), " ", var_export(0 == "a", true), " ", var_export(null == false, true), " ", var_export([] == false, true), "\n";unknown
true false true trueThe first line prints unknown, the null coalescing operator papering over a missing key. The second prints true false true true, and each of those is a loose comparison rule: two numeric strings compare as numbers, null equals false, and an empty array equals false. Go has none of this. Equality is strict, between values of the same type, and a missing value is the zero value or a nil pointer. For optional nested data, structs with pointer fields give you the check, and the JavaScript article shows the same decode with a nested missing object. The practical effect for a PHP developer is fewer silent type juggling bugs, and more explicit if statements at the boundaries where data enters.
Exceptions become errors
PHP throws and catches, with exception classes and finally. Go returns an error and the caller checks it:
<?php
function parsePort(string $text): int {
if (!ctype_digit($text)) {
throw new InvalidArgumentException("port must be a number, got \"$text\"");
}
$port = (int) $text;
if ($port < 1 || $port > 65535) {
throw new RangeException("port $port out of range");
}
return $port;
}
foreach (["8080", "http", "70000"] as $value) {
try {
echo parsePort($value), "\n";
} catch (Exception $e) {
echo "error: ", $e->getMessage(), "\n";
}
}8080
error: port must be a number, got "http"
error: port 70000 out of rangepackage main
import (
"fmt"
"strconv"
)
func parsePort(text string) (int, error) {
port, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("port must be a number, got %q", text)
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("port %d out of range", port)
}
return port, nil
}
func main() {
for _, value := range []string{"8080", "http", "70000"} {
port, err := parsePort(value)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println(port)
}
}8080
error: port must be a number, got "http"
error: port 70000 out of rangeIdentical output. The control flow differs: PHP unwinds to the nearest catch, and Go returns to the caller. In a web handler the pattern becomes if err != nil { http.Error(w, ..., status); return }, which is the Go counterpart of a global exception handler mapping exceptions to responses, and handling errors in HTTP handlers shows how to centralize it. There is no finally: cleanup is defer, as defer explains. And an uncaught panic in a goroutine takes down the whole process, where PHP would end one request, so a production server recovers at the request boundary, which the standard net/http server does for you per request, logging the panic and keeping the process alive.
Frameworks and the standard library
A Laravel developer is used to a framework doing routing, validation, ORM, queues and views. Go's culture is the reverse: the standard library is the framework. net/http is a production-grade server, and since Go 1.22 its ServeMux supports methods and path parameters, so a small API needs no router package. encoding/json, html/template, database/sql, log/slog and testing cover most of the rest. People add a router such as Chi or Gin, a query layer such as sqlc and a migration tool when the project calls for it, and the choice is theirs, not the framework's. Expect to write more wiring by hand, and to understand every layer of your service when you are done. The upside is a service that starts in milliseconds, holds its state in one process, and has no vendor of a thousand files to deploy.
A Laravel route in the standard library
To see how little a small API needs, here is the equivalent of Route::get('/users/{id}') and Route::post('/users'), exercised with httptest so it runs without opening a port:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s\n", r.PathValue("id"))
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "created")
})
for _, c := range []struct{ method, path string }{
{"GET", "/users/42"},
{"POST", "/users"},
{"DELETE", "/users/42"},
{"GET", "/nope"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, strings.NewReader("")))
fmt.Printf("%-6s %-10s -> %d %s", c.method, c.path, rec.Code, rec.Body.String())
}
}GET /users/42 -> 200 user 42
POST /users -> 201 created
DELETE /users/42 -> 405 Method Not Allowed
GET /nope -> 404 404 page not foundThe method and the {id} wildcard are part of the pattern, and r.PathValue reads the segment. A request whose path matches but whose method does not gets a 405 with an Allow header, and an unknown path gets a 404, both generated by the mux itself. Middleware is a function that takes a handler and returns one, so the equivalent of Laravel's route middleware is a wrapper you compose by hand. Validation, which Laravel does with form requests, is ordinary code in the handler or a small function that decodes the body and returns an error, and that explicitness is what most PHP developers say they miss least after a month and understand best after six.
Fixing the shared counter
Back to the data race, because the fix shows two Go tools a PHP developer needs. For a counter, an atomic integer is the right size of tool. For anything with more than one field, a mutex is:
package main
import (
"fmt"
"net/http/httptest"
"sync"
"sync/atomic"
)
type stats struct {
mu sync.Mutex
byPath map[string]int
}
func (s *stats) hit(path string) int {
s.mu.Lock()
defer s.mu.Unlock()
if s.byPath == nil {
s.byPath = map[string]int{}
}
s.byPath[path]++
return s.byPath[path]
}
func main() {
var total atomic.Int64
var s stats
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func() {
defer wg.Done()
rec := httptest.NewRecorder()
total.Add(1)
s.hit([]string{"/a", "/b"}[i%2])
fmt.Fprint(rec, "ok")
}()
}
wg.Wait()
fmt.Println(total.Load(), s.hit("/a"), s.hit("/b"))
}20 11 11Twenty simultaneous callers produced a total of exactly 20, and each of the two paths was hit ten times before the final calls, which printed as 11 each. The stats type has a usable zero value: the map is created on first use inside the lock, which follows the zero value rule and avoids a constructor. This is the structure that replaces PHP's "state lives in the session, the cache or the database" for data that is truly per process, and anything that must survive a restart or be shared across several server instances still belongs in Redis or the database, exactly as it does in PHP.
Views: Blade and html/template
A server-rendered page in Laravel is a Blade template, and Go's standard library has html/template, which escapes by context so that output is safe by default. The behavior to compare is escaping, the thing PHP developers learn to do by hand with htmlspecialchars:
<?php
$name = "<b>ada</b> & co";
echo htmlspecialchars($name, ENT_QUOTES), "\n";<b>ada</b> & copackage main
import (
"html/template"
"os"
)
func main() {
tpl := template.Must(template.New("page").Parse("<p>Hello, {{.Name}}</p>\n"))
data := struct{ Name string }{"<b>ada</b> & co"}
if err := tpl.Execute(os.Stdout, data); err != nil {
panic(err)
}
}<p>Hello, <b>ada</b> & co</p>Both turn the angle brackets and the ampersand into entities, so the markup in the name cannot become markup in the page. In PHP you must remember to call htmlspecialchars or use Blade's {{ }} syntax, and in Go the default of html/template is to escape, and you have to opt out with a template.HTML type to emit raw markup. The second behavior is the better default for security, and the security material on handlers builds on it. template.Must is the Must convention for setup code that cannot fail at run time, as the Must function article describes. A template that needs a layout, partials and caching is a few more lines, or a small package, and many Go services skip server rendering and serve JSON to a front end instead.
Classes, traits and interfaces
PHP classes become structs with methods. Interfaces work like PHP's but are satisfied implicitly: no implements, as interfaces in Go develops. Traits, PHP's mechanism for reusing methods, correspond roughly to embedding a struct, which promotes its methods into the outer type, though embedding is not inheritance and an embedded type cannot call the outer one. Constructors are NewThing functions. Magic methods such as __get and __call have no equivalent, and that is a feature: every method call is resolvable at compile time. Dependency injection containers are rarer, because passing dependencies as constructor arguments in main is easy to read, and the wiring is a few lines, as functional options and constructors show.
Composer, deployment and the day-to-day
composer.json becomes go.mod, with exact versions and a checksum file, and composer install is go mod download, which the build does for you. There is no autoloader and no namespace configuration: a package is a directory and its import path is the module path plus the directory, as packages explains. Deployment is the biggest change. There is no PHP-FPM pool to size, no OPcache to warm and no web server module. You build one binary, go build, and run it, usually behind a reverse proxy or in a container from scratch. Tests run with go test, and there is no PHPUnit equivalent to install. The editor experience is as good as PHP's, through gopls.
Two habits from PHP need adjusting on the first day. Sessions are not built in: $_SESSION worked because PHP-FPM gave every request a file or a store behind a cookie, and in Go you choose a signed cookie, a Redis-backed store or a library for it, and you decide where the data lives. Configuration follows the same logic. There is no .env loader in the standard library, so a Go service usually reads environment variables with os.Getenv at start-up, validates them once in main, and passes the result down as a struct. Failing fast on a missing variable at boot, instead of discovering it on the first request that needs it, is the kind of small discipline the language rewards.
What to keep, and what PHP still does better
Keep your sense of web architecture, your testing discipline and your habit of separating a request's input handling from the business logic: that structure transfers directly. Leave the global helpers, the facade-style static access and the reliance on a framework container. PHP remains a very good choice for content sites, WordPress and Laravel applications where a team already has deep expertise and the shared-nothing model is an advantage, since a bug in one request cannot corrupt the next. Go is the better fit for long-running services, high-concurrency APIs, command line tools and anything that benefits from a single binary. Many teams run both, with Laravel for the application and Go for the pieces that need speed or concurrency. The Tour of Go is the interactive start, and Writing Web Applications is the official tutorial for a first server.


