Go para desenvolvedores PHP: o que muda na forma de pensar
Paulo Roberto Bolsanello·17 Set 2026·19 min de leitura
Quem trabalha com PHP há alguns anos construiu um reflexo: salva o arquivo, atualiza o navegador, vê o resultado. Em Go esse reflexo não funciona. O código passa pelo compilador antes de virar programa, os tipos precisam fechar antes de qualquer execução e os erros não sobem sozinhos pela pilha até alguém capturar.
Nada disso é complicado. A sintaxe do Go é pequena e se aprende num fim de semana. O que atrasa o desenvolvedor PHP nas primeiras semanas são os hábitos construídos em anos de Laravel, Symfony e PHP-FPM, que em Go não encontram onde se apoiar.
Este artigo trata desse deslocamento. Começa por três pontos concretos, tipagem, tratamento de erros e compilação, e termina num roteiro de estudo para quem está dando os primeiros passos.
Duas linguagens, dois problemas diferentes
O PHP nasceu em 1994 de um punhado de scripts CGI escritos em C por Rasmus Lerdorf para contar as visitas ao currículo dele na web. Virou linguagem depois, empurrado pelo uso. A origem explica bastante coisa: barreira de entrada baixa, tolerância a tipos, um modelo em que cada requisição roda do zero e termina. WordPress, Wikipédia e Tumblr foram construídos em cima disso.
O Go veio de um incômodo diferente. Em 2007, Robert Griesemer, Rob Pike e Ken Thompson começaram a desenhar na Google uma linguagem para o atrito que aparece quando o software cresce: compilação lenta, dependências que se multiplicam, código que ninguém entende seis meses depois. A ideia era juntar a velocidade de execução do C com uma sintaxe que coubesse na cabeça de um time inteiro. O lançamento público foi em 2009.
Guardar essa diferença ajuda a engolir as escolhas do Go que parecem implicância nos primeiros dias. Quando o compilador rejeita uma variável declarada e não lida, ele está aplicando a premissa de que ler código sai mais caro que escrever.
Mudança 1: o tipo passa a valer antes da execução
Quem já usa declare(strict_types=1) tem meio caminho andado. O PHP 7 trouxe declarações de tipo em parâmetros e retornos, o 7.4 estendeu para propriedades e o 8.3 para constantes de classe. A linguagem sem tipos de 2010 não existe mais.
A diferença está no momento da verificação. No PHP o tipo é conferido em tempo de execução, mesmo em modo estrito: a função é chamada, o valor não bate, TypeError. O erro só existe porque a linha rodou. Some-se a isso o fato de que strict_types vale por arquivo e se aplica às chamadas feitas dentro dele, o que rende surpresas quando um arquivo estrito chama código de um arquivo que não declarou nada.
<?php
declare(strict_types=1);
function applyDiscount(float $price, int $percent): float
{
return $price - ($price * $percent / 100);
}
echo applyDiscount(100, 10); // 90
echo applyDiscount("100", "10"); // TypeError, mas só ao executar esta linha
Em Go a mesma tentativa não chega a virar binário. O build para, e a mensagem aponta arquivo e linha antes de qualquer coisa subir:
func applyDiscount(price float64, percent int) float64 {
return price - (price * float64(percent) / 100)
}
func main() {
fmt.Println(applyDiscount(100, 10)) // 90
fmt.Println(applyDiscount("100", "10")) // não compila
}
// cannot use "100" (untyped string constant) as float64
// value in argument to applyDiscount
Repare no float64(percent). Go não converte tipos numéricos por conta própria: multiplicar um float64 por um int é erro de compilação e a conversão precisa ser escrita à mão. Isso pega todo mundo que vem do PHP, onde "10" + 5 se resolve sozinho. Em troca, some da sua vida a categoria inteira de bug causada por coerção silenciosa.
Outra diferença prática: Go trabalha com inteiros com e sem sinal, de int8 a int64 e de uint8 a uint64, enquanto o PHP só tem inteiros com sinal. No código de aplicação você vai usar int na maior parte do tempo, mas a distinção aparece assim que o assunto envolve bytes, protocolos binários ou drivers de banco.
E antes que a impressão de verbosidade se instale: Go infere tipos. A declaração curta com := resolve o tipo a partir do valor, então você escreve bem menos anotação do que a fama da linguagem sugere.
name := "Paulo" // string
price := 19.90 // float64
quantity := 3 // int
var items []string // slice nil, já pronta para append
Mudança 2: erro não é exceção, é valor de retorno
Esta é a virada que mais incomoda no início e a que mais muda o desenho do código depois.
No PHP o erro interrompe o fluxo e sobe pela pilha até encontrar um catch. Se ninguém capturar, a aplicação morre. O efeito colateral é que o caminho de falha some da assinatura: olhando para readConfig(string $path): array, nada indica que ela pode estourar de duas formas diferentes. Pior: a assinatura promete array, mas json_decode devolve mixed, então um JSON válido que contenha só um número também derruba a função, agora por causa do tipo de retorno.
Go não tem try/catch. Uma função que pode falhar devolve dois valores, o resultado e um error. A falha entra no contrato, fica visível na assinatura e o compilador coloca a decisão na sua frente a cada chamada.
O bloco if err != nil se repete, e essa é a crítica mais comum à linguagem. Ela tem fundamento: o arquivo fica mais longo. O que se ganha em troca é que a falha aparece na assinatura e a decisão sobre ela fica no mesmo ponto onde ela acontece, em vez de esperar um catch genérico três camadas acima.
O verbo %w dentro de fmt.Errorf embrulha o erro original e preserva a cadeia. Mais adiante, errors.Is compara com um erro conhecido e errors.As extrai um tipo específico. Juntos, são o equivalente funcional de capturar exceção por classe.
config, err := readConfig("config.json")
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Fatal(err)
}
config = defaultConfig()
}
Go tem panic e recover, e a tentação de usá-los como substitutos de throw e catch aparece na primeira semana. Resista. A convenção reserva panic para o que não dá para continuar, tipo erro de programação ou falha na inicialização do processo. Arquivo ausente, JSON quebrado e usuário não encontrado são situações comuns e voltam como valor.
Regra prática para começar: se você escreveu panic em código de aplicação, provavelmente queria devolver um error.
Mudança 3: o compilador entra no meio do caminho
Numa stack PHP tradicional o fluxo é conhecido: o servidor web entrega a requisição ao PHP-FPM, que executa o código e devolve a resposta. Com OPcache ligado, e em produção ele deveria estar, o fonte é compilado para opcodes uma vez e reaproveitado. O que se repete a cada requisição é a execução desses opcodes pela VM, somada ao ciclo de montar e derrubar o estado da aplicação. Em qualquer cenário, o runtime precisa estar instalado no servidor, na versão certa, com as extensões certas.
Em Go, go build produz um binário único e autossuficiente, com runtime e garbage collector embutidos. O servidor de destino não precisa ter Go instalado. Deploy vira copiar um arquivo e executar. A compilação cruzada resolve o resto:
go run main.go # compila e executa, para desenvolvimento
go build -o api . # binário para a plataforma atual
GOOS=linux GOARCH=amd64 go build -o api # binário Linux a partir do macOS ou do Windows
Uma ressalva sobre a última linha, porque ela aparece em todo tutorial sem aviso: a compilação cruzada só é indolor enquanto o projeto for Go puro. Assim que entra cgo, o que acontece com alguns drivers de SQLite e bibliotecas que embrulham código C, você precisa de um toolchain C para a plataforma de destino ou de um contêiner que compile lá dentro.
A parte que exige mais adaptação não é o build. É o feedback. O compilador do Go recusa código que quase toda linguagem aceita, e recusa cedo:
Variável declarada e nunca lida quebra a compilação. Atribuir valor a ela não conta como uso.
Import declarado e não usado quebra a compilação.
Chave em posição errada muda o significado do programa, por causa da inserção automática de ponto e vírgula.
O último item é o erro que todo mundo comete no primeiro dia. O trecho abaixo parece correto para quem vem do PHP e não compila:
if "test" == "t" { test = 0 }
else if "t" == "t" { test = 1 } // syntax error: unexpected else
O motivo é que o Go insere um ponto e vírgula ao fim de toda linha terminada em chave de fechamento. O else da linha seguinte fica órfão. A forma correta mantém a cadeia inteira na mesma linha lógica:
package main
import (
"fmt"
"time"
)
func main() {
iterations := 1000000
start := time.Now()
var test int
for i := 0; i < iterations; i++ {
if "test" == "t" {
test = 0
} else if "t" == "t" {
test = 1
}
}
fmt.Println("Execution time:", time.Since(start))
fmt.Println("Resultado:", test)
}
A última linha está ali por um motivo específico. Sem ela, test só recebe atribuições e nunca é lido, e o compilador rejeita o programa com "declared and not used". São detalhes assim que fazem o primeiro dia render menos do que o previsto, e que depois passam a trabalhar a seu favor: boa parte do que você descobriria em produção, ou numa suíte de testes, aparece no terminal em milissegundos. O compilador assume um papel que no PHP fica dividido entre PHPStan, Psalm e testes automatizados.
Esse mesmo trecho, aliás, é um bom exercício de desconfiança. Ele costuma ser usado para mostrar que o Go é dezenas de vezes mais rápido que o PHP em condicionais, e o número que ele produz não significa o que parece. As duas comparações são entre constantes conhecidas em tempo de compilação e test não influencia nada além da impressão final. O compilador dobra as constantes e esvazia o laço. Os 368 microssegundos reportados para um milhão de iterações dão menos de meio nanossegundo por volta, rápido demais até para um processador moderno executar um desvio condicional. O que o teste mede é a capacidade de otimização do compilador, não o custo de um if. O PHP, que executa opcodes um a um na VM, não tem como fazer esse corte. A direção do resultado continua verdadeira; a magnitude não se transfere para código real.
Structs no lugar de classes, composição no lugar de herança
O PHP organiza o mundo em classes, herança e polimorfismo. Go não tem classes nem herança. Os dados vivem em structs, métodos são funções associadas a um tipo, e o reúso vem de composição e de interfaces implícitas.
type Notifier interface {
Notify(message string) error
}
type EmailNotifier struct {
From string
}
func (n EmailNotifier) Notify(message string) error {
// envia o e-mail
return nil
}
Não existe implements nesse código. EmailNotifier satisfaz Notifier porque tem o método com a assinatura certa, e só. A relação entre tipo e interface não é declarada em lugar nenhum, é verificada pelo compilador no momento em que você usa um no lugar do outro.
Um detalhe que derruba quem vem do PHP: o receptor importa. Se o método fosse escrito como func (n *EmailNotifier) Notify(...), apenas *EmailNotifier passaria a satisfazer a interface, e entregar um EmailNotifier por valor deixaria de compilar. A regra prática é escolher um estilo de receptor por tipo e manter a coerência.
A consequência prática do resto é grande para quem vem de Laravel ou Symfony. Interfaces em Go tendem a ser pequenas, muitas vezes com um método só, e ficam declaradas no pacote que consome o comportamento, não no que o implementa. Você pode criar uma interface depois que o tipo já existe, inclusive só para conseguir testar, sem encostar no código original. Não sobra hierarquia para manter nem árvore de classes para discutir em code review.
O array do PHP vira três coisas
O array do PHP é um canivete suíço: lista, mapa ordenado, pilha, fila. Go separa essas responsabilidades em tipos distintos, e a diferença aparece já na primeira função que você escrever.
Array: tamanho fixo definido na declaração, [5]int. O tamanho faz parte do tipo, então [5]int e [6]int são tipos diferentes. Pouco usado no dia a dia.
Slice: visão dinâmica sobre um array, []int. É o que você vai usar quase sempre, crescendo com append.
Map: tabela de chave e valor, map[string]int. Diferente do array associativo do PHP, não preserva ordem.
Essa última linha custa caro a quem ignora. A ordem de percurso de um map em Go é deliberadamente aleatória, um sorteio a cada execução, justamente para impedir que alguém passe a depender dela. Se o seu código precisa de ordem, extraia as chaves e ordene:
keys := make([]string, 0, len(config))
for k := range config {
keys = append(keys, k)
}
sort.Strings(keys)
No controle de fluxo há outra simplificação: Go não tem while. Todo laço, condicional ou infinito, usa for.
for i := 0; i < 10; i++ { } // clássico
for condition { } // equivalente ao while
for { } // laço infinito
for i, v := range items { } // equivalente ao foreach
Composer e Go Modules: parecidos só na superfície
O PHP centraliza dependências no Composer, com o composer.json descrevendo o que o projeto precisa e o Packagist fazendo o papel de registro. Em Go o gerenciamento é nativo da linguagem, via Go Modules, declarado no go.mod.
A diferença conceitual é que não existe um Packagist do Go. O caminho do módulo é o endereço do repositório e é de lá que o código sai. Existe infraestrutura pública no meio do caminho, só que com outro papel: por padrão o download passa pelo proxy em proxy.golang.org e os hashes são conferidos contra o banco de checksums em sum.golang.org, com o resultado gravado no go.sum. Para módulos privados, GOPRIVATE tira essas duas etapas do caminho.
No fluxo diário some o composer install como passo separado. go build e go test resolvem e baixam o que falta, go mod tidy limpa o que sobrou e acrescenta o que ficou faltando, e go mod download existe para quando você quer só popular o cache, tipo numa camada de build do Docker.
Concorrência como recurso da linguagem
O modelo mais comum em PHP é um processo por requisição, sem estado compartilhado entre elas. Paralelismo existe, mas no nível do pool do FPM. Para trabalho concorrente dentro da própria aplicação a resposta costuma ser infraestrutura: fila, worker, Horizon, Redis, supervisor. Isso funciona bem e continua fazendo sentido em muitos casos. Runtimes como Swoole, RoadRunner e FrankenPHP mudam esse quadro, e as fibers do PHP 8.1 abriram espaço para concorrência cooperativa, mas nenhum deles é o padrão do ecossistema.
Go traz o assunto para dentro da linguagem. Goroutines são threads leves gerenciadas pelo runtime, que nascem com uma pilha de cerca de 2 KB e crescem conforme a necessidade. Channels são o canal de comunicação entre elas. Disparar trabalho concorrente custa uma palavra-chave.
func main() {
urls := []string{"https://a.dev", "https://b.dev", "https://c.dev"}
var wg sync.WaitGroup
results := make(chan string, len(urls))
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
resp, err := http.Get(u)
if err != nil {
results <- fmt.Sprintf("%s: erro %v", u, err)
return
}
defer resp.Body.Close()
results <- fmt.Sprintf("%s: %d", u, resp.StatusCode)
}(url)
}
wg.Wait()
close(results)
for r := range results {
fmt.Println(r)
}
}
As três requisições acontecem ao mesmo tempo, sem broker, sem worker, sem supervisor. Duas observações sobre o código, porque elas separam o exemplo de tutorial do código que vai para produção. O parâmetro u está ali por hábito: até o Go 1.21 a variável do range era compartilhada entre as iterações e fechar sobre ela gerava o bug clássico de todas as goroutines enxergarem o último valor. Do 1.22 em diante cada iteração ganha a própria cópia, e usar url direto passa a ser seguro, desde que o go.mod declare a versão. A segunda observação é mais séria: http.Get usa o cliente padrão, que não tem timeout nenhum. Em produção, monte um http.Client com Timeout ou passe um context com prazo, ou uma goroutine pendurada numa conexão lenta vai ficar pendurada para sempre.
Antes de montar uma fila externa em Go, pergunte se o problema não se resolve com goroutines e um channel. Em agregação de dados, chamadas paralelas a APIs e processamento em lote, resolve na maioria das vezes.
Os números, e o que eles não dizem
Benchmark entre linguagens pede leitura cuidadosa: muda com a versão, com o ajuste do servidor e com a forma como o teste foi escrito. Os dados abaixo vêm de medições publicadas por Deepak Sabat e pela Scimus, ambas de 2024, e servem para dar ordem de grandeza, não para encerrar discussão.
Cenário de API REST
Vazão em 1 milhão de requisições: 80.000 req/s em Go contra 25.000 req/s em PHP
Uso de memória RAM: 150 MB em Go contra 400 MB em PHP
Uso de CPU: 60% em Go contra 80% em PHP
Consultas SQL SELECT em 500 mil execuções: 60.000 qps em Go contra 20.000 qps em PHP
Latência média em SQL: 1,5 ms em Go contra 5,0 ms em PHP
Resposta de API JSON: 0,1134 s em Go contra 0,1669 s em PHP
Há um resultado que contraria a tendência e que ganha destaque justamente por isso. Na renderização de HTML no servidor o PHP foi mais rápido no teste da Scimus: 0,1952 s contra 0,7509 s do Go. Faz sentido. O PHP foi desenhado para exatamente essa tarefa e tem quase três décadas de otimização nela. Se o seu produto é um site com renderização no servidor e templates pesados, trocar de linguagem pode não trazer nada parecido com o que o resto da tabela promete.
Micro-benchmarks de processamento, reportados por João Victor no DEV Community:
Hello World: 1,5 ms e 3,1 MB em Go contra 52 ms e 52 MB em PHP
Binary Trees: 2.665 ms e 44,2 MB em Go, com timeout e 141,7 MB em PHP
Merkle Trees: 1.632 ms e 39,1 MB em Go contra 4.001 ms e 113,7 MB em PHP
Condicional else if em 1 milhão de iterações: 368,9 µs em Go contra 20.998 µs em PHP
O Hello World mede sobretudo o custo de inicialização do interpretador, que o binário compilado não paga. A diferença é real em execução curta e repetida, como função serverless e comando de linha, e some num processo de longa duração. O teste de condicionais, como já vimos, mede otimização de compilador. Os dois casos de árvores são os mais informativos da lista, porque envolvem alocação de verdade e mostram o consumo de memória em escala.
A conclusão honesta é mais modesta que a tabela: em cargas de API, processamento e concorrência, o Go entrega mais trabalho por servidor com bem menos memória. Isso é diferente de dizer que Go é melhor que PHP.
O que costuma incomodar nas primeiras semanas
Verbosidade no tratamento de erros. O if err != nil aparece muito. Incomoda menos quando o padrão vira leitura automática.
Sem sobrecarga de métodos. Não dá para declarar a mesma função com assinaturas diferentes. A saída idiomática são nomes distintos ou um struct de opções.
Sem parâmetro com valor padrão. Mesma história: ou você passa tudo, ou usa um struct de configuração.
Tamanho do binário. Com runtime e garbage collector embarcados, um programa simples já nasce com alguns megabytes. Remover símbolos de depuração com -ldflags="-s -w", usar build tags e, em último caso, comprimir com UPX resolve quando isso importa.
Ausência de framework dominante. Não existe um Laravel do Go. A biblioteca padrão cobre HTTP, JSON, SQL, testes e criptografia, e o resto você monta peça por peça. Para quem vem de um framework completo, essa liberdade parece abandono no começo.
O roteiro para o desenvolvedor PHP trilhar
A ordem abaixo não é a de um curso de sintaxe. É a ordem em que os hábitos precisam ser trocados, e é ela que determina quanto tempo leva até você produzir código Go razoável.
Desaprender a herança clássica. Antes de tentar reproduzir sua arquitetura de classes em Go, escreva structs pequenas e interfaces definidas por comportamento. Se você está pesquisando como fazer herança em Go, o modelo ainda não virou.
Adotar o tratamento explícito de erros. Verifique o erro a cada chamada, embrulhe com %w quando for propagar e reserve panic para o que é de fato irrecuperável.
Dominar a concorrência nativa. Goroutines e channels antes de qualquer infraestrutura de fila. Comece com sync.WaitGroup e depois estude context, que é como Go faz cancelamento e prazo.
Explorar a biblioteca padrão.net/http, encoding/json, database/sql, testing. Passe algumas semanas construindo só com o que vem na caixa antes de instalar o primeiro pacote de terceiros. É a melhor forma de entender por que o Go tem menos frameworks: ele precisa de menos.
Um bom primeiro exercício é reescrever em Go um endpoint que você já mantém em PHP. Algo com leitura de banco, serialização JSON e uma chamada externa. O escopo é conhecido, o resultado é comparável e os três pontos deste artigo, tipo, erro e compilação, aparecem todos no mesmo arquivo.
A palavra "harness" tomou conta das conversas sobre agentes de IA em 2026, e por um bom motivo: ela descreve a peça que decide se um agente sobrevive fora de uma demonstração bonita e passa a resolver problema real em produção. Neste artigo, explico o que é um agent harness, mostro como ele aparece na prática num fluxo real de entrega de software (do épico até o deploy) e discuto por que a qualidade dessa camada importa mais do que o modelo de linguagem escolhido.
A ordem é: primeiro limpar o Docker, depois compactar o VHDX O ext4.vhdx é o disco virtual do WSL. Ele cresce conforme você usa, mas não encolhe sozinho quando você apaga coisas dentro do WSL. Por isso, mesmo que você…