Skip to content
gohacks.dev
Basics

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.

13 min read
Summarize in AI
Flat illustration of an editor window full of code, with the PHP logo.

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
<?php
static $count = 0;
$count++;
echo $count, "\n";
Text
1

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

Shell
printf '<?php\nstatic $count = 0;\n$count++;\necho $count, "\\n";\n' > c.php
for i in 1 2 3; do php c.php; done
Text
1
1
1

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

main.goGo
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())
	}
}
Text
1
2
3

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

main.goGo
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()
}
Text
==================
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
<?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";
Text
["ada","linus"]
main.goGo
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))
}
Text
["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
<?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";
Text
unknown
true false true true

The 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
<?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";
    }
}
Text
8080
error: port must be a number, got "http"
error: port 70000 out of range
main.goGo
package 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)
	}
}
Text
8080
error: port must be a number, got "http"
error: port 70000 out of range

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

main.goGo
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())
	}
}
Text
GET    /users/42  -> 200 user 42
POST   /users     -> 201 created
DELETE /users/42  -> 405 Method Not Allowed
GET    /nope      -> 404 404 page not found

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

main.goGo
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"))
}
Text
20 11 11

Twenty 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
<?php
$name = "<b>ada</b> & co";
echo htmlspecialchars($name, ENT_QUOTES), "\n";
Text
&lt;b&gt;ada&lt;/b&gt; &amp; co
main.goGo
package 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)
	}
}
Text
<p>Hello, &lt;b&gt;ada&lt;/b&gt; &amp; 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.