3 February 2026
8 mins

Go in a PHP world: adding high-performance services without rebuilding everything

Go in a PHP world, article image

The question that comes up eventually

PHP runs. The product grows. And at some point one specific service stops keeping up.

Not the whole system, one concrete bottleneck. An API that collapses under load. A service that starts sweating at high concurrency. A part of the architecture that makes it obvious PHP is no longer the right answer for exactly this use case.

The obvious reaction is a rewrite. I have seen how that ends. The pragmatic answer is a different one: replace the bottleneck deliberately without touching the rest. Go for the parts that need performance. PHP for everything that already runs and has no reason to change.

How PHP and Go talk to each other

The first architecture decision was the most important one: how do PHP services and Go services communicate with each other?

REST was the obvious answer. I decided against it, for everything that communicates between services in the backend. gRPC over Protobuf is the internal standard.

The reasons are practical. Protobuf enforces explicit contracts. Changes to the interface are immediately visible as a schema diff instead of an implicit behaviour change nobody documented. And performance that makes a measurable difference at high throughput.

A simplified Protobuf contract for an auth service looks like this:

syntax = "proto3";

package auth.v1;

option go_package = "github.com/acme/auth/gen;auth";

service AuthService {
  rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
}

message ValidateTokenRequest {
  string token = 1;
}

message ValidateTokenResponse {
  string user_id        = 1;
  repeated string roles = 2;
  bool   valid          = 3;
}

From this definition both the Go server code and the PHP client code are generated automatically. PHP talks to the Go service without anyone having to write serialization by hand.

The PHP client then looks like this:

$client = new AuthServiceClient('auth-service:50051', [
    'credentials' => Grpc\ChannelCredentials::createInsecure(),
]);

$request = new ValidateTokenRequest();
$request->setToken($token);

[$response, $status] = $client->ValidateToken($request)->wait();

if ($status->code !== Grpc\STATUS_OK || !$response->getValid()) {
    throw new UnauthorizedException();
}

$userId = $response->getUserId();

REST stays in the stack anyway, but with clearly defined boundaries. Health endpoints run over REST because monitoring tools and load balancers speak HTTP. Everything directly customer-facing does too, because browsers and mobile clients do not speak gRPC. The nice part: a REST gateway can be generated automatically from a single Protobuf definition via the gRPC gateway proxy. I write the contract once and get both protocols without a duplicate implementation.

More on this in the official documentation: grpc-ecosystem/grpc-gateway

Where I started

The first Go service was authentication. Not because of performance, auth was not the bottleneck. But because auth is structurally ideal for a first cut. Clearly bounded, defined input and output, few external dependencies. The team learns Go in production without a single mistake putting the whole system at risk.

After that came the payment gateway. That was the actual performance driver. High load, strict latency requirements, complex integration with external payment providers. PHP had reached its limits here. Go had not.

The order was no accident. With auth, the team had learned Go in production before it went near the critical service.

The real problem: the mental model

The biggest challenge was not technical. It was in people's heads.

PHP developers think in request-response cycles. A request comes in, gets processed, a response goes out. For the duration of the request the process belongs to that one request. That is so deeply ingrained it becomes unconscious, even though PHP does technically offer ways to work outside that model.

Go thinks fundamentally differently. The concurrency model with goroutines and channels is not a feature you reach for when needed, it is the way Go works under load. Ignore that and you build services that feel like PHP but do not perform like Go.

That is exactly what happened. The first Go implementations in the team processed requests sequentially. Not because anyone made a mistake, but because nobody had deliberately thought in terms of concurrency. The code was correct. It just was not concurrent. Under load that became visible immediately: the service meant to replace PHP was slower than its predecessor.

The fix was technically trivial. The understanding behind it was not. A simple example that shows the difference:

// Sequential, PHP thinking in Go
func processRequests(requests []Request) []Result {
    results := make([]Result, len(requests))
    for i, req := range requests {
        results[i] = process(req) // waits for each result before moving on
    }
    return results
}

// Concurrent, Go thinking
func processRequests(requests []Request) []Result {
    results := make([]Result, len(requests))
    var wg sync.WaitGroup

    for i, req := range requests {
        wg.Add(1)
        go func(i int, req Request) {
            defer wg.Done()
            results[i] = process(req) // runs in parallel
        }(i, req)
    }

    wg.Wait()
    return results
}

The second difference was idiomatic Go. PHP OOP patterns do not work in Go. Inheritance hierarchies, abstract classes, magic methods: none of that exists in Go, and for good reason. Interfaces in Go are implicit. Composition beats inheritance. Anyone trying to translate PHP OOP into Go fights the language instead of working with it.

The shift took time. Pair programming helped more than documentation. Once you have seen how a concurrent Go service reacts under load, you intuitively understand why the model works the way it does, and you never want to go back.

Observability as the operational foundation

The second big challenge was observability. In a mixed stack of PHP and Go on AWS it gets more complicated than in a homogeneous stack.

The problem is consistency. When a request runs from a PHP frontend service to a Go auth service to a Go payment service, the trace ID has to be carried through all three services. Without that, debugging under load is detective work.

OpenTelemetry solved this, as a shared standard across both languages. In Go, trace propagation over gRPC looks like this:

import (
    "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
    "google.golang.org/grpc"
)

conn, err := grpc.NewClient(
    target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
    grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()),
)

PHP services and Go services export traces in the same format. Datadog collects all of it. A request through the entire system is visible as a single trace, no matter which language handled which part.

Without that visibility a mixed stack is harder to operate than a homogeneous one, no matter how well the individual services work. Observability is not a nice-to-have. It is the precondition for keeping the mixed stack operationally manageable.

What counts in the end

PHP and Go do not exclude each other. They complement each other. But only once you stop thinking in one language and start thinking in systems.

The hardest part was never the code. It was letting go of patterns that had worked for years. The request-response thinking that sits so deep it only becomes visible when it stops working. The OOP patterns that are elegant in PHP and work against the language in Go. The concurrency model you cannot read about; you have to experience it.

A mixed stack of PHP and Go is not a stopgap. It is a deliberate architecture decision that uses the best of both worlds. PHP stays where it is strong. Go takes over where performance and concurrency count. gRPC makes sure both worlds talk to each other cleanly.

What is left is a system that scales without having to be rewritten.