Backend Architecture

From PHP Monolith to Services Without Creating a Distributed Monolith

Splitting a monolith trades in-process complexity for network complexity. Here is how to decide whether that trade is worth making, and how to make the first cut so it survives production.

Most plans to move a PHP monolith to microservices start from the same observation: the codebase is hard to change, deploys are frightening, and one team’s release blocks four others. Sometimes splitting is the right response. Often it is the most expensive possible misdiagnosis, because splitting does not remove complexity. It converts in-process complexity, which a stack trace, a debugger and a single database transaction can explain, into network complexity, which requires distributed tracing, timeout budgets, retry semantics, idempotency and someone carrying a pager. That trade can be worth it. It is still a trade.

Microservices are a trade, not an upgrade

A well-structured modular monolith beats a badly cut set of services, and it is not close. In a monolith, a call between modules either returns or throws. Over the network, the same call has a third outcome that is much worse than failure: it timed out, and you do not know whether the other side did the work. Every boundary you create multiplies the states your system can be in.

Property Modular monolith Services
Cross-module call Deterministic, in-process Timeout, duplicate, partial success
Consistency One transaction, one COMMIT Eventual, needs reconciliation
Refactoring a boundary An IDE rename A versioned contract and two deploys
Debugging Stack trace Correlated traces across services
Deploy independence No Yes, if data is owned
Independent scaling Only whole app Per service

The two rows at the bottom are the reasons to pay for the four rows above. If you do not need those two rows, you are buying the cost without the benefit. That configuration has a name: the distributed monolith. Services that must be deployed together, that share tables, and that cannot be reasoned about separately give you the operational overhead of distribution with none of the autonomy.

Why teams move from a PHP monolith to microservices

Organizational reasons

These are the reasons that actually hold up. When twelve engineers in four teams share one deploy pipeline, coordination cost grows faster than the codebase. Conway’s law describes the result: your architecture will drift toward the shape of your communication structure whether you plan for it or not. If two teams own one artifact, they will negotiate every release.

Concretely, the symptoms worth splitting for are: a CI suite so long that nobody runs it locally, a merge queue where changes wait hours behind unrelated work, release trains where a bug in reporting rolls back a payments fix, and a review culture where nobody feels ownership because everyone owns everything. These are people problems with an architectural solution.

Technical reasons

Legitimate technical drivers are narrower than people expect. Divergent resource profiles are the clearest: a PDF renderer or image pipeline saturates CPU while your HTTP API is mostly waiting on PostgreSQL, and both scale on the same PHP-FPM pool. Blast radius is another: a memory-hungry export job should not be able to exhaust the pool that serves checkout. And runtime fit matters, long-lived connections, high-concurrency fan-out and always-on consumers are genuinely awkward in a process-per-request model and genuinely comfortable in Go.

“We want to write Go” is not a boundary. It is a preference, and it produces service cuts that follow the team’s enthusiasm rather than the data’s ownership.

Finding boundaries that survive contact with production

Start from data, not from directory layout. The question is not “which classes belong together” but “which rows change together inside one transaction”. Pull your slow query log and your ORM’s generated SQL and build a table-access map: for each module, which tables does it read, which does it write, and which transactions span more than one candidate boundary. Write-coupling is the signal that matters; read-coupling can be solved with a replicated read model, but two writers to the same rows means you have one service pretending to be two.

Then apply three tests to each candidate:

  1. Invariant test. Can this service enforce its own rules with only its own data? If validating an order requires reading the customers table directly, the boundary is in the wrong place or the contract is missing.
  2. Contract test. Can you write the interface on one page, a handful of endpoints or message types, with explicit failure semantics? If the contract needs thirty operations, you have extracted a database, not a service.
  3. Ownership test. Is there one team that can change this service’s schema without asking anyone? If not, deploy independence is theoretical.

Beware the “user service” pattern, where the most-referenced entity is extracted first. Everything then calls it synchronously on every request, and you have built a central bottleneck with a network hop in front of it. Extract the leaves first, not the trunk. If your monolith is Symfony, the bundle and service-container layout usually encodes some real boundaries already, and the practical considerations for extracting Symfony code into Go services are a reasonable place to sanity-check your candidate list.

Database ownership is the real boundary

If two deployables write the same table, they are one service. This is the single rule that separates a real split from theatre. Shared-database access means every schema migration is a coordinated deploy across repositories, every lock contention incident involves two on-call rotations, and no team can enforce an invariant because another writer can violate it at any moment.

DISTRIBUTED MONOLITH
--------------------
  svc A ---+
           |   all write directly
  svc B ---+--> [ orders ] <-- monolith
           |
  svc C ---+

  * schema change = 4 coordinated deploys
  * no owner, so no enforceable invariants
  * lock contention crosses team boundaries

OWNED BOUNDARY
--------------
  monolith --HTTP--> [ orders service ]
                            |
                     [ orders db ]  private
       <---events----       |

  * one writer, one migration owner
  * contract = API + event schema
  * failure is visible and localised

Getting to the right side of that diagram is a sequence, and each step is shippable on its own:

  • Stop cross-module writes inside the monolith first. Route all writes to a table through one module’s repository or service class, and enforce it with static analysis so violations fail CI rather than review.
  • Stop cross-module reads next. Replace joins across the boundary with a call to the owning module, or with a denormalised read model the owner maintains.
  • Move the tables to their own schema, revoke the other role’s grants, and see what breaks. This is the honest test, and it is much cheaper to fail here than after extraction.
  • Only then extract the process.

You also have to accept what you give up. There is no COMMIT that spans two databases. Cross-service foreign keys do not exist. Reads that used to be one join become either an API call or a maintained copy, and copies go stale. Everything you previously got for free from a single PostgreSQL instance now becomes application code you own and test.

The synchronous API trap

Replacing an in-process call with an HTTP call looks harmless in a diagram and misbehaves in three specific ways.

Latency multiplies, and tails multiply faster than medians. Suppose, purely as an illustration, a request fans out to four services, each with a 15 ms median and a 150 ms p99. The medians add up to something acceptable. But the chance that at least one of four independent hops lands in its own p99 is far higher than one percent, so your endpoint’s p99 is dominated by whichever dependency is having the worst minute. Sequential fan-out makes this arithmetic worse; parallel fan-out only bounds it by the slowest hop.

Failures cascade. Without a per-dependency timeout that is shorter than the caller’s own deadline, one slow service consumes every worker upstream. This is sharper in PHP than in Go for a structural reason: a blocking outbound call occupies an entire PHP-FPM child for its duration. If a child holds, say, 120 MB resident and your pool allows 40 children, your maximum concurrency during a dependency slowdown is a memory ceiling, not a CPU one. In Go, a goroutine blocked on I/O costs kilobytes, so the same fan-out degrades far more gracefully. That asymmetry is a real argument for writing the fan-out layer in Go, and a reason to be careful about introducing fan-out from PHP at all.

Retries turn a blip into an outage. Naive retries triple load exactly when a service is least able to absorb it. Anything you retry needs a retry budget, full jitter on backoff, a circuit breaker, and an idempotency key so that a retried write does not create a second charge.

Events and eventual consistency

Asynchronous boundaries avoid most of the above, which is why the strongest first cuts are usually event-driven. The price is that “done” becomes “eventually done”.

Publish through a transactional outbox: write the row and the event in the same local transaction, then relay the event to the broker. Without it you get the classic pair of bugs, a committed row with no event, or an event for a transaction that rolled back. Assume at-least-once delivery, because that is what brokers give you, and make every consumer idempotent with a natural key or a dedupe table. Order only within a key, never globally. Version event schemas from day one and treat additive changes as the only safe ones.

Publish facts, not rows. OrderPaid with the fields consumers need is a contract you can evolve; a change-data-capture stream of your orders table is your schema leaked into every consumer, and it will freeze your migrations.

Then plan for the human consequences. Support will ask why a customer’s invoice was not visible for eight seconds. You need read-your-writes handling in the UI for the paths where it matters, a reconciliation job that detects drift between services, and a metric for consumer lag that someone actually looks at.

The strangler pattern in practice

You do not need a big-bang cutover. Put a router at the edge, keep the monolith as the default, and move one route or one message type at a time behind a flag, with the ability to flip back in seconds. The step-by-step mechanics of running a strangler migration from PHP to Go matter more than the architecture diagram, because the migration’s risk lives entirely in the switching layer.

Choosing and building the first service

Pick the first extraction for evidence, not for impact. Good candidates share four properties: clear data ownership, an asynchronous interface, no seat on the checkout critical path, and a measurable output you can compare against the old implementation. Notification delivery, export and report generation, webhook ingestion, search indexing and document rendering all qualify. Session handling, authorization and the customer entity do not.

Ship it with the boring parts included: structured logs, a health endpoint that reflects dependencies, metrics for rate, errors and duration, a runbook, and a rollback path. The first service establishes the template every later one will copy, including the mistakes.

This is also the point where an outside opinion is cheapest. Deciding what should stay in PHP, what should be modernised, and what is genuinely worth extracting into Go is a decision you make once and live with for years; a fixed-scope readiness audit that maps write-coupling, tail latency and deploy pain before anyone writes code costs far less than one wrongly placed boundary.

Observability across a split

Before the second service exists, you need to be able to answer “where did this request spend its time” without SSH. Generate a correlation ID at the edge, propagate it as traceparent, and put it in every structured log line. Emit rate, errors and duration per endpoint and per dependency, separately, an error budget that mixes your own faults with a dependency’s is unusable. Track queue depth and consumer lag as first-class alerts.

One specific regression to watch for: a join you removed at the boundary often reappears as an N+1 pattern over HTTP. Watch calls-per-request, not just latency, and keep running EXPLAIN ANALYZE on the owner’s queries after extraction, because the query planner’s choices change when access patterns and table statistics change.

Rollback

Design the retreat before the advance. Concretely, that means: the router flag flips without a deploy; database migrations follow expand-and-contract so the old code path still runs against the new schema; the monolith’s implementation stays in the tree for at least two releases rather than being deleted in the same PR; rollout is a canary by tenant or percentage with predefined abort thresholds; and any data the new service wrote during the canary is either readable by the old path or reconcilable by a script you have already tested. If you cannot describe the rollback in three sentences, the cutover is not ready.

When to keep the monolith

I get paid to make this call correctly, not to maximise the amount of Go in the world, so here is the honest version. Keep the monolith if a single deploy is not your actual pain; if your latency problem is a missing index, a sequential scan or an N+1 query, in which case start with what to fix before rewriting anything; if you have no on-call rotation, no tracing and no way to run a canary; if the boundary you have chosen requires two writers to one table; or if the team is small enough that coordination cost is a rounding error.

In every one of those cases the higher-return work is the same, and none of it requires distribution: enforce module boundaries with static analysis, split into separate PostgreSQL schemas with distinct roles, remove cross-module SQL, extract read models, move to a current PHP 8.x release, and configure OPcache and preloading properly. Do that and you keep the option to split later, from a codebase where the boundaries are already visible. Skip it and you will extract the tangle you have into three repositories, where it is harder to see and much harder to fix.

The teams that succeed at this treat the split as a series of reversible experiments with a measurable hypothesis each time. The ones that struggle treat it as a migration project with a deadline and no exit criteria.