A PHP monolith can move to Go one flow at a time, with no big-bang rewrite. The old code keeps serving customers while a new Go service runs beside it, fed by events through SNS and SQS. Ports and adapters let that service go from watching in the background to answering real requests without changing its business rules.
I have led a PHP monolith to Go migration, service by service, on a platform that stayed in production. The design below is how I would run that kind of migration now, not a record of that project.

Customers only touch the top half. If everything in the bottom half stops, nobody outside the team notices.
The example is a flow that calculates an order's total with tax. It makes a good first flow because the same input should give the same output, so the old and new code can be compared.
Step 1: Put a port in the monolith
Before writing any Go, wrap the old code in an interface. Behavior does not change. The monolith now has one place where it asks for a total:
interface PricingGateway{ public function totalCents(array $lines, DateTimeImmutable $placedAt): int;}
final class LegacyPricing implements PricingGateway{ public function totalCents(array $lines, DateTimeImmutable $placedAt): int { return calculate_order_total($lines, $placedAt); // the old code, untouched }}The legacy code is now an adapter, the same idea as the Adapter pattern. It stays where it is and keeps working. Nothing gets thrown away at this stage.
Step 2: Publish events with an outbox
Publishing to SNS right after the MySQL commit can lose an event: the commit succeeds, the process dies, and SNS never hears about the order. So the event goes into an outbox table, in the same transaction as the order:
$pdo->beginTransaction();
$total = $pricing->totalCents($lines, $placedAt);$orderId = $orders->insert($customerId, $lines, $total, $placedAt);
$pdo->prepare('INSERT INTO outbox (event_type, payload) VALUES (?, ?)')->execute([ 'OrderPlaced', json_encode([ 'event_id' => bin2hex(random_bytes(16)), 'order' => [ 'id' => $orderId, 'lines' => $lines, 'placed_at' => $placedAt->format(DATE_RFC3339), ], 'legacy_total_cents' => $total, ], JSON_THROW_ON_ERROR),]);
$pdo->commit();A small worker publishes what is pending:
$rows = $pdo->query( 'SELECT id, event_type, payload FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100')->fetchAll(PDO::FETCH_ASSOC);
$markPublished = $pdo->prepare('UPDATE outbox SET published_at = NOW() WHERE id = ?');
foreach ($rows as $row) { $sns->publish([ 'TopicArn' => $topicArn, 'Message' => $row['payload'], 'MessageAttributes' => [ 'event_type' => ['DataType' => 'String', 'StringValue' => $row['event_type']], ], ]);
$markPublished->execute([$row['id']]);}If the worker dies between publishing and marking the row, the event goes out twice. Consumers have to handle that anyway, as step 5 shows.
The event carries everything the calculation used, including the time and the legacy result. The Go service may run seconds or hours later, and it should not read a tax rate that changed in between. Keep card numbers and personal data out of events.
Step 3: Fan out with SNS and SQS
Use one SNS topic and one SQS queue per consumer. The monolith publishes once and does not know who is listening, so the next service only needs a new subscription, with no PHP change. If the Go service is down or slow, messages wait in its queue and customers feel nothing.
aws sns subscribe \ --topic-arn "$ORDERS_TOPIC_ARN" \ --protocol sqs \ --notification-endpoint "$PRICING_QUEUE_ARN" \ --attributes RawMessageDelivery=trueRaw message delivery makes the message body the event JSON, without the SNS envelope. The queue also needs an access policy that lets the topic send to it, a visibility timeout longer than handling a message takes, and a dead-letter queue.
Step 4: Build the Go service as a hexagon

The core holds the business rules and knows nothing about AWS, HTTP or MySQL. A driving adapter calls the core. A driven adapter is called by it, through an interface the core defines, which is the port:
// TaxRates is a driven port. The core says what it needs, not where it comes from.type TaxRates interface { RateAt(ctx context.Context, at time.Time) (basisPoints int64, err error)}
type Service struct { rates TaxRates}
func (s *Service) TotalCents(ctx context.Context, o Order) (int64, error) { var subtotal int64 for _, l := range o.Lines { subtotal += l.Quantity * l.UnitCents }
rate, err := s.rates.RateAt(ctx, o.PlacedAt) if err != nil { return 0, fmt.Errorf("tax rate for order %s: %w", o.ID, err) }
return subtotal + (subtotal*rate+5000)/10000, nil}The parallel run gets its own small use case and its own port. Both go away at the end of the migration:
// Comparisons is a driven port that only exists during the parallel run.type Comparisons interface { Record(ctx context.Context, c Comparison) error}
type Shadow struct { pricing *Service comparisons Comparisons}
func (s *Shadow) Handle(ctx context.Context, e OrderPlaced) error { total, err := s.pricing.TotalCents(ctx, e.Order) if err != nil { return err }
return s.comparisons.Record(ctx, Comparison{ EventID: e.EventID, OrderID: e.Order.ID, Legacy: e.LegacyTotal, New: total, })}SQS appears only in the adapter that turns messages into calls:
func (c *Consumer) Run(ctx context.Context) error { for ctx.Err() == nil { out, err := c.Client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(c.QueueURL), MaxNumberOfMessages: 10, WaitTimeSeconds: 20, }) if err != nil { log.Printf("receive: %v", err) time.Sleep(time.Second) continue }
for _, m := range out.Messages { if err := c.handle(ctx, m); err != nil { // Not deleted, so SQS redelivers it and eventually moves it to the dead-letter queue. log.Printf("message %s: %v", aws.ToString(m.MessageId), err) continue } _, err := c.Client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: aws.String(c.QueueURL), ReceiptHandle: m.ReceiptHandle, }) if err != nil { log.Printf("delete %s: %v", aws.ToString(m.MessageId), err) } } } return ctx.Err()}
func (c *Consumer) handle(ctx context.Context, m types.Message) error { var e pricing.OrderPlaced if err := json.Unmarshal([]byte(aws.ToString(m.Body)), &e); err != nil { return err } return c.Shadow.Handle(ctx, e)}Tests fake the ports, so the rules run with no queue or database. More on that in Testing in Golang:
type fixedRate int64
func (r fixedRate) RateAt(context.Context, time.Time) (int64, error) { return int64(r), nil }Step 5: Run in parallel and compare
Deploy the service and let it consume. It has no route to customers, and it can only touch the outside world through the adapters wired into it. A shadow service that sends email or charges cards would do it twice, so here its only driven adapters read tax rates and record comparisons.
The comparison table has a unique key on event_id, so a redelivered message changes nothing:
INSERT IGNORE INTO pricing_comparisons (event_id, order_id, legacy_cents, new_cents)VALUES (?, ?, ?, ?);Then read what disagrees:
SELECT order_id, legacy_cents, new_centsFROM pricing_comparisonsWHERE legacy_cents <> new_cents;Suppose the legacy code ends with (int) ($subtotal * 1.13 * 100). An order of $19.99 comes out as 2258 cents, because the cast truncates. The Go code rounds and gets 2259.
Each mismatch is a bug in the new code or a rule nobody wrote down. Sometimes the old behavior is wrong but accounting or customers depend on it, so decide case by case and write the decision down.
Move on when every remaining mismatch has an explanation and the run has covered your busy days and rare cases, such as month-end or a tax change.
Step 6: Cut over behind a flag
Add a driving adapter for real requests. It calls the same TotalCents, so the rules you just verified do not change. This example uses HTTP. gRPC works the same way.
func TotalHandler(svc *pricing.Service) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var o pricing.Order if err := json.NewDecoder(r.Body).Decode(&o); err != nil { http.Error(w, "invalid order", http.StatusBadRequest) return }
total, err := svc.TotalCents(r.Context(), o) if err != nil { log.Printf("total: %v", err) http.Error(w, "pricing failed", http.StatusInternalServerError) return }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]int64{"total_cents": total}) })}The monolith gets a second adapter behind the port from step 1, and a flag picks between them:
final class GoPricing implements PricingGateway{ public function __construct(private ClientInterface $http) {}
public function totalCents(array $lines, DateTimeImmutable $placedAt): int { $response = $this->http->request('POST', '/orders/total', [ 'json' => ['lines' => $lines, 'placed_at' => $placedAt->format(DATE_RFC3339)], 'timeout' => 0.5, ]);
return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR)['total_cents']; }}
final class FlaggedPricing implements PricingGateway{ public function __construct( private PricingGateway $legacy, private PricingGateway $go, private Flags $flags, ) {}
public function totalCents(array $lines, DateTimeImmutable $placedAt): int { $pricing = $this->flags->enabled('go-pricing') ? $this->go : $this->legacy;
return $pricing->totalCents($lines, $placedAt); }}Turn the flag on for internal orders, then a small share of customers, then everyone. Rolling back means turning it off, because the legacy adapter is still there.
Step 7: Retire the old code
Once you no longer expect to turn the flag off, delete LegacyPricing and the code behind it, the flag, Shadow, the Comparisons port, and the SQS adapter and its subscription. The core does not change. The outbox and the topic stay, because the next flow uses them.
| Stage | Who answers customers | Go service | Legacy code |
|---|---|---|---|
| Parallel run | PHP | Consumes events and records comparisons | Live |
| Cutover | Go or PHP, by flag | Answers flagged requests | Live, used when the flag is off |
| Done | Go | Answers every request | Deleted |
Then repeat with the next flow. Each one gets easier, because the outbox, the topic and the habit of comparing are already there.
What can go wrong
- Duplicate messages. Standard SNS topics and SQS queues deliver at least once. Give each event an ID and make the consumer ignore one it has already seen.
- Messages out of order. Standard queues do not guarantee order. Here each event stands alone, so order does not matter. If a flow depends on it, put the full state and a version in each event, or use FIFO topics and queues with the order ID as the message group.
- A message that always fails. After enough failed deliveries, SQS moves it to the dead-letter queue. Watch that queue during the parallel run, because each message in it is an order you did not compare.
- Two writers on one table. During the parallel run Go writes only to its own comparison table. At cutover, give each table one owner.
- Changing the database too soon. Keep the existing MySQL data model at first. The MySQL adapter hides the legacy column names from the core, so a later schema change only touches that adapter. Splitting the database is a separate project.
- An event format that drifts. PHP writes the JSON and Go reads it, and no compiler checks both sides. Keep a payload generated by the PHP code in the Go tests.
When this is too much
A small monolith with good tests can move flow by flow with no events at all. A read-only endpoint can be compared by calling both versions from the monolith or a proxy. A flow whose result depends on an external call or on randomness cannot be compared this way, because the two runs will not agree even when both are correct.
SNS and SQS are one choice among several. The monolith only depends on the outbox and the Go core only depends on its ports, so another broker changes the relay and one adapter.