Symfony to Go Migration: An Incremental Strategy
How to add Go services to a long-lived Symfony platform one workload at a time, Doctrine boundaries, Messenger transports, tracing and rollback, while product work continues.
A Symfony to Go migration usually arrives on the agenda for a system that has been running successfully for years, which is exactly why it needs to be approached carefully. Long-lived Symfony products tend to be well structured. The bundle system, dependency injection with compile-time container checks, the event dispatcher, Messenger, and a strong culture of layered architecture mean that a ten-year-old Symfony codebase is often in better shape than a three-year-old service mesh. Before you plan anything, be clear about the premise: mature Symfony code is not technical debt simply because it is PHP.
What can be true at the same time is that a few specific workloads inside that platform are constrained by the PHP execution model, connection-heavy endpoints, high-throughput consumers, CPU-bound processing, and that those workloads would be cheaper and more predictable as small Go services. The strategy below is about introducing Go one workload at a time, without a freeze on product development and without a cutover event that has no rollback.
What long-lived Symfony products actually look like
The systems where this question comes up share a shape. There is a core domain built over many years, usually with a real separation between domain, application and infrastructure layers. There is Doctrine, with a schema that encodes a decade of business decisions and a set of entity relationships that are the actual specification of the business. There is Messenger handling asynchronous work, an API Platform or hand-rolled REST surface, and a back office that internal staff use every day.
There is also, typically, a set of known pressure points: a nightly export that takes hours, an import endpoint that falls over when a partner sends a burst, a notification fan-out that is slower than the business would like, and a worker fleet whose memory footprint is disproportionate to what it computes.
Those pressure points are the migration scope. The domain core is not.
Domain logic and where it should live
The single most important decision is where the domain model lives after the migration, and the default answer should be: exactly where it is now.
Symfony domain code is where the expensive knowledge sits, the invariants, the edge cases, the regulatory rules, the reasons behind conditions that look arbitrary. Reimplementing that in Go means re-deriving knowledge you already paid for, from code that is the only complete specification. There is no test suite thorough enough to make that safe in one pass.
The workable division is that Go services should own mechanical work: transport, fan-out, streaming, transformation, protocol handling, high-volume ingestion. Symfony keeps decisions: what a valid order is, when a subscription renews, how a price is calculated, who may see what.
When a Go service genuinely needs a business decision, it should ask for it, a synchronous call to a Symfony endpoint, or a message that Symfony consumes, rather than reimplementing it. A duplicated business rule in two languages will diverge; the only question is when.
Doctrine, and what breaks when Go writes to its schema
Doctrine is not a thin query builder, and this is where most Symfony-plus-Go setups get into trouble.
A Go service connecting to a Doctrine-managed schema sees tables. It does not see the mapping metadata, and the mapping metadata is where a large part of the behaviour lives:
| Doctrine behaviour | What a Go writer misses |
|---|---|
| Lifecycle callbacks and event listeners | Side effects that never fire |
| Discriminator columns (inheritance) | Rows written with the wrong type |
| Custom DBAL types | Wrong on-disk representation |
Optimistic locking (version column) |
Lost updates under concurrency |
| Cascade persist / remove | Orphaned or dangling rows |
| Owning vs inverse side of relations | Join rows written on the wrong side |
| Identity map and flush ordering | Constraint violations from ordering |
| Second-level cache | Symfony reading stale data |
The practical rules that follow:
Start read-only. A Go service that only reads is a small, reversible commitment, and reads cover a surprising number of useful workloads, exports, search indexing, feeds, reporting.
If Go must write, give it tables Doctrine does not map at all. An events table, an outbox, an ingestion staging table, a processed-keys table. Symfony can read those tables through plain DBAL queries without turning them into entities.
Never let Go write to an entity table with a version column, inheritance mapping, or lifecycle listeners. Send a message to Symfony instead and let Doctrine perform the write. Mapping this ownership boundary table by table is usually the first deliverable when I take on incremental PHP to Go extraction work, because getting it wrong is the failure mode that surfaces weeks later as quietly inconsistent data.
Keep schema migrations in one place, Doctrine Migrations. Go services get a read-only database user by default, and a role with narrowly-granted write access on the specific tables they own. That is both a correctness boundary and a sensible least-privilege posture.
Queues, Messenger, and sharing a transport
Symfony Messenger is the most natural integration point, because a transport is a protocol boundary rather than a framework boundary.
The complication is the envelope. Messenger serializes messages with its own serializer by default, wrapping the payload in a structure with stamps and a PHP class name in the headers. A Go consumer cannot deserialize a PHP-serialized object and should not try.
The fix is to configure a JSON-based serializer for the specific transport that Go participates in, and to treat the message body as a versioned, language-neutral contract:
Symfony ──▶ transport (JSON serializer) ──┬─▶ PHP consumer
└─▶ Go consumer
Go ──▶ transport (same JSON contract) ──▶ Symfony consumer
Some practical points. Use a dedicated transport for cross-language messages rather than reusing async, it keeps the serializer configuration isolated and makes routing explicit. Include a type and a version field in every payload body so both sides can dispatch and evolve independently. When Go publishes messages back to Symfony, it must produce headers Messenger recognises, which means the Go publisher needs a small, tested helper rather than ad-hoc JSON.
Retries and failure handling become a shared concern. Messenger has its own retry strategy and failure transport; your Go consumer needs an equivalent, and the two should agree on what a poison message looks like and where it goes. Design for at-least-once delivery on both sides: idempotency keys derived from the payload, stored with a unique index, written in the same transaction as the effect.
API boundaries and the routing layer
Put a routing layer in front of everything before you deploy a single Go service. Nginx, Traefik, or your existing gateway all work. The requirement is that moving a path from Symfony to Go is a config change, revertible in under a minute, and capable of splitting traffic by percentage.
Keep the boundary at the path level rather than inside the application. Symfony should not proxy to Go, and Go should not proxy to Symfony, because that hides the topology and makes latency attribution hard.
Contracts should be explicit. Symfony teams often already have OpenAPI documents via API Platform or NelmioApiDocBundle; extend that discipline to the Go services and validate both sides against the same specification in CI. Content negotiation, error shape, pagination format, and date serialization should be identical, mismatched date formats between a PHP and a Go implementation of “the same” endpoint is a classic, and it will be a client’s problem before it is yours.
Workers and the carve-outs worth making
The workloads that pay for themselves in Go are consistent across Symfony platforms:
- High-volume consumers with heavy I/O wait. A
messenger:consumeprocess holds the container for its lifetime and handles one message at a time. Suppose, illustratively, a consumer sits at 140 MB resident and spends 85 percent of each message waiting on an external API. Concurrency then costs memory linearly. A Go consumer with a bounded pool holds the same number of in-flight messages in one process. - Streaming exports. A nightly job that builds a large CSV or XML in memory is a memory-limit incident waiting to happen. Go streams naturally from a database cursor to a response or object storage with a small, constant footprint.
- Ingestion endpoints. Webhooks and partner feeds that arrive in bursts. Accept, verify, persist to a Go-owned staging table, acknowledge, and let Symfony process at its own pace.
- Persistent connections. SSE or WebSocket delivery for dashboards and notifications.
- CPU-bound transformation. Format conversion, hashing, compression, image work.
And the carve-outs that are usually a mistake: the back office, form-heavy admin flows, anything built on API Platform’s serialization and filtering, and any module whose rules change with each quarter’s product roadmap. Those are precisely where Symfony’s productivity is highest.
Deployment without a second operations culture
Two runtimes are acceptable. Two philosophies of running software are not.
Use one pipeline shape, one secret store, one environment naming convention, one health-check contract, one deployment approval process. Go services should implement graceful shutdown on SIGTERM, stop accepting new work, finish in-flight messages, close the database pool, mirroring what a well-configured messenger:consume with --time-limit does.
Resource limits deserve explicit thought. In PHP you sized the worker pool against memory per process. In Go you set GOMAXPROCS in line with the container’s CPU quota and GOMEMLIMIT below the container limit, so the garbage collector works with the orchestrator rather than being killed by it.
Observability across the PHP and Go boundary
If you get one thing right beyond table ownership, make it this.
Use OpenTelemetry on both sides with the same service naming convention and the same span attribute keys. Propagate traceparent in HTTP headers and in message metadata, so a request that enters Symfony, dispatches a Messenger message, and is handled by a Go consumer appears as a single trace. Without this you will spend incident time arguing about which side is slow instead of looking at a waterfall.
Emit the same structured log fields from both, request ID, trace ID, tenant, user, message ID, with identical key names. Different key names for the same concept makes correlation queries painful in every log backend.
Metrics should be comparable: the same histogram buckets for latency, the same counters for messages processed, failed, and retried, labelled by service. When you canary a Go consumer against the PHP one, you want to overlay two lines on one chart, not reconcile two measurement schemes.
Rollback, and why it must stay cheap
Every extraction should have a rollback that is a configuration change, not an engineering project. Practically, that means:
Keep the Symfony implementation deployable and tested for the entire canary period and for at least a month after full cutover. Do not delete the old consumer in the same release that promotes the new one.
Avoid destructive schema changes during a migration window. Adding a column is reversible; dropping one is not, and a rollback that requires restoring a backup is not a rollback.
Verify the rollback path before you need it. Route traffic back to Symfony deliberately, during business hours, while someone is watching. A rollback path that has never been exercised is a hypothesis.
For consumers, the equivalent is being able to stop the Go consumer and let the PHP one drain the queue. That only works if both are genuinely idempotent and if you have not changed the message contract in an incompatible way, which is the real argument for versioning payloads from the first day.
Sequencing the work
Instrument, then measure, then extract one thing. Fix the query plans and the N+1 patterns first, because a meaningful share of “we need Go” conversations end there. Pick one workload with a clear contract and real pain. Run it in shadow mode and diff outputs against Symfony. Canary by percentage. Hold. Re-measure. Then decide whether a second extraction is justified by the data rather than by the plan.
If you want the workload classification, table ownership map and extraction order produced by someone who is willing to tell you that nothing should move, that is what a migration readiness audit delivers as a fixed-scope engagement. The same reasoning applies at a larger scale when the question is service decomposition rather than language choice, see breaking a PHP monolith into services, and the general framing of what belongs in Go at all is covered in the practical PHP to Go migration guide.
The end state most Symfony platforms should aim for is not “Symfony replaced by Go.” It is a Symfony application that still owns the domain, with two or three small Go services doing the mechanical, high-volume work around it, one shared schema with clear ownership, one trace across both, and a team that can still ship a product change on a Tuesday.