The Strangler Fig Pattern in Practice: Gradually Migrating a PHP Monolith to Microservices via REST API
Introduction: What Is the Strangler Fig Pattern and Why It's the Best Approach for PHP Projects
The Strangler Fig pattern was described by Martin Fowler in 2004 and named after a tropical plant that gradually wraps around its host tree and ultimately replaces it. Applied to software architecture, the idea is straightforward: instead of rewriting the system from scratch, you incrementally extract individual pieces of functionality into new services while keeping the monolith running throughout the process.
For PHP projects — especially those built on Laravel or Symfony — this approach is particularly relevant. Most large PHP applications have accumulated functionality over years, and their codebases have become difficult to maintain. A full rewrite is the classic "second-system" trap: enormous risks, a feature freeze, and unpredictable timelines. The Strangler Fig lets you move iteratively, preserving business value at every step.
The key advantage of the pattern is the ability to run the monolith and new microservices in parallel through a single entry point, gradually redirecting traffic. REST API serves as the natural interaction contract here, while the API Gateway acts as the central orchestration element.
Analyzing the Monolith: How to Identify Future Service Boundaries
Before extracting the first service, you need to perform domain mapping — analyzing the business domain and identifying natural boundaries between modules. In the context of a Laravel monolith, this means studying the structure of models, controllers, and the dependencies between them.
Analysis Tools
- Database relationship analysis — tables with a minimal number of foreign keys pointing to other domains are the first candidates for extraction.
- Change heatmap — modules that change independently of one another are easier to isolate.
- Coupling/cohesion metrics — high internal cohesion and low external coupling indicate a good boundary for a future service.
- Event Storming — a collaborative session with the team to identify domain events and aggregates.
Practical Signs of a Good Service Boundary
- The module has its own data lifecycle and does not share transactions with other modules.
- The teams working on the module can deploy it independently.
- The REST API for the module can be described without exposing the internal structure of other domains.
- The module has a clearly defined "owner" within the team.
Typical first candidates in PHP projects include: authentication and session management, notifications (email, push, SMS), file storage, billing, and search. These modules often have a high deployment frequency and minimal dependencies on the business core.
The Role of the API Gateway: Routing Between the Monolith and New Services
The API Gateway is the heart of the Strangler Fig pattern. It receives all incoming requests and decides whether to route them to the legacy PHP monolith or to a new microservice. This allows clients — frontends and mobile apps — to work with a single endpoint without knowing anything about the internal changes.
API Gateway Implementation Options
- Nginx with dynamic configuration — a minimalist option for getting started, suitable for simple path-prefix routing rules.
- Kong Gateway — a powerful solution with plugin support, rate limiting, JWT validation, and detailed logging.
- AWS API Gateway / GCP Cloud Endpoints — cloud solutions with built-in scalability.
- Traefik — a lightweight reverse proxy with native Docker and Kubernetes integration, convenient when containerizing services.
The basic configuration principle: for each service being extracted, a routing rule is added that intercepts requests to a specific path and forwards them to the new service. All other requests go to the monolith by default.
# Example Nginx configuration for the Strangler Fig pattern
upstream monolith {
server php-monolith:80;
}
upstream auth_service {
server auth-go-service:8080;
}
upstream notification_service {
server notification-service:8081;
}
server {
listen 80;
# New Auth service intercepts requests
location /api/v1/auth/ {
proxy_pass http://auth_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Notification service
location /api/v1/notifications/ {
proxy_pass http://notification_service;
proxy_set_header Host $host;
}
# Everything else goes to the PHP monolith
location / {
proxy_pass http://monolith;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
It is critically important to implement a circuit breaker at the Gateway level: if the new service is unavailable, requests must automatically fall back to the monolith. This ensures zero service degradation if problems arise during the migration.
Step-by-Step Implementation: Extracting the First Service
Successful monolith decomposition requires a strict sequence of actions. Improvisation is dangerous here — every step must be reversible.
Step 1: Define the Public API of the Future Service
Before writing any code for the new service, describe its REST API in OpenAPI 3.0 format. This establishes a contract that both the new service and its consumers must follow. The contract locks in the endpoints, request and response schemas, and error codes.
Step 2: Build the New Service
The new service implements the described API. At this stage it may internally access the monolith's database (if the database split has not yet happened) through a dedicated read-only replica or a separate user with restricted permissions.
Step 3: Parallel Execution (Shadow Mode)
Before switching traffic, run the new service in "shadow" mode: the Gateway duplicates requests to both the monolith and the new service simultaneously, but returns only the monolith's response to the client. The new service's responses are logged and compared. This lets you identify discrepancies without any risk to users.
Step 4: Gradual Traffic Switching (Canary Deployment)
Start with 5–10% of traffic routed to the new service, monitoring error rate, latency, and business metrics. Gradually increase the share to 100%. Once things stabilize, remove the corresponding logic from the monolith.
Step 5: Remove Dead Code from the Monolith
This step is often skipped, but it is critically important. Code left in the monolith after migration creates a false sense of reliability and accumulates technical debt. Once 100% of traffic has been switched to the new service, the corresponding code in the monolith must be deleted.
Data Management: Database Splitting Strategies
Handling data is the most complex part of monolith decomposition. In most PHP applications, all data is stored in a single database, and many tables are used by multiple domains simultaneously.
The "Database per Service" Strategy
Each microservice should have its own database or schema. This ensures independent deployment and scaling. However, transitioning to this model takes time and requires intermediate steps.
Intermediate Tactics
- Shared Database, Separate Schema — in the early stages, services share a single PostgreSQL server but use different schemas. This reduces operational complexity while preserving logical isolation.
- Database Views — for services that need data from "foreign" tables, read-only views are created. This fixes the public data contract.
- Change Data Capture (CDC) — tools like Debezium track changes in the monolith's tables and stream them to the new database via a message queue (Kafka, RabbitMQ). This provides eventual consistency without a direct coupling between services.
- Dual Write — during the transition period, the application writes data to both the old and new databases simultaneously. This requires careful handling of partial failures.
Dealing with Shared Tables
The users table is a classic example of a shared table in PHP monoliths — virtually every module accesses it. The splitting strategy: extract only the fields that belong to the domain being separated. For example, authentication fields (password_hash, remember_token, last_login_at) move to the auth service, while profile fields (name, avatar, bio) remain in a user-profile service or the monolith.
Practical Example: Extracting Authentication from Laravel into Go
Let's look at a concrete case: a Laravel monolith with a users table and standard Laravel authentication. The goal is to move authentication into a standalone service written in Go.
Why Go for the Auth Service?
Go delivers low latency, minimal memory consumption, and straightforward concurrency management — all critical characteristics for a high-load authentication service. It also demonstrates that the Strangler Fig pattern doesn't lock you into a single language.
Structure of the New Auth Service in Go
// auth-service/main.go
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
type AuthHandler struct {
db *sql.DB
jwtSecret []byte
}
// POST /api/v1/auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var userID int64
var passwordHash string
// Read from the auth schema, replicated from the monolith
err := h.db.QueryRow(
`SELECT id, password FROM auth.users WHERE email = $1 AND deleted_at IS NULL`,
req.Email,
).Scan(&userID, &passwordHash)
if err == sql.ErrNoRows {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
expiresAt := time.Now().Add(24 * time.Hour)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": expiresAt.Unix(),
"iss": "auth-service",
})
tokenString, err := token.SignedString(h.jwtSecret)
if err != nil {
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Token: tokenString,
ExpiresAt: expiresAt,
})
}
func main() {
db, err := sql.Open("postgres", "postgres://auth_user:secret@postgres:5432/app_db?sslmode=require")
if err != nil {
log.Fatal(err)
}
defer db.Close()
handler := &AuthHandler{
db: db,
jwtSecret: []byte("your-secret-key"),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/auth/login", handler.Login)
log.Println("Auth service listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The Transition Period: Dual Write in Laravel
During the migration, the Laravel monolith continues working with the users table, but all password-related operations are duplicated into the auth schema. In Laravel code, this is implemented via an Observer or a decorator over the Auth facade. Once the new service's stability is confirmed, Dual Write is disabled and Laravel delegates authentication to the Go service via an internal REST API call.
Testing During Migration
Migrating without reliable testing is like walking a tightrope without a safety net. Key strategies:
Contract Testing for REST APIs
Use Pact or Dredd for contract testing: the consumer (e.g., the frontend or the monolith) defines the expected contract, and the provider (the new service) verifies that it conforms. This protects against breaking changes as the API evolves.
Shadow Mode Comparison
During the parallel operation period, automatically compare the responses from the monolith and the new service. Discrepancies are logged and trigger alerts. For a Laravel PHP monolith, it's convenient to use middleware that duplicates requests and compares JSON responses, ignoring timestamp fields.
Regression Testing
End-to-end tests must cover critical paths that pass through the API Gateway, regardless of whether they are handled by the monolith or the new service. Recommended tools: Postman/Newman for API tests, Playwright for e2e.
CI/CD for a Hybrid Environment
Running the monolith and microservices in parallel adds extra complexity to CI/CD processes. A few guiding principles:
Independent Pipelines
Each microservice must have its own build, test, and deploy pipeline. The monolith should not be a dependency for deploying a new service. In GitLab CI or GitHub Actions, this is achieved through separate workflow files with trigger conditions based on changes in the corresponding directories.
API Versioning and Gateway Configuration
Changes to the API Gateway configuration must be part of Infrastructure as Code (Terraform, Pulumi) and go through the same review process as application code. New routes are activated via feature flags, allowing rollback without redeployment.
Example Repository Structure
monorepo/
├── monolith/ # Laravel PHP monolith
│ ├── app/
│ ├── .github/workflows/monolith-ci.yml
│ └── Dockerfile
├── services/
│ ├── auth-service/ # Go auth service
│ │ ├── main.go
│ │ ├── .github/workflows/auth-service-ci.yml
│ │ └── Dockerfile
│ └── notification-service/ # Next service
│ └── ...
├── infrastructure/
│ ├── nginx/ # API Gateway configuration
│ │ └── strangler.conf
│ ├── terraform/ # IaC
│ └── docker-compose.yml # Local development
└── contracts/ # OpenAPI contracts
├── auth-api.yaml
└── notification-api.yaml
Health Checks and Circuit Breaker in the Pipeline
After deploying a new service, the CI/CD pipeline must verify its health endpoint before switching traffic. If the health check fails, the Gateway continues routing requests to the monolith. This ensures zero downtime regardless of any issues that arise.
Migration Success Metrics
Track the following indicators throughout the entire migration:
- Traffic share on new services — the target value grows from 0% to 100% for each extracted domain.
- Latency P95/P99 — the new service must not degrade in response time compared to the monolith.
- Error Rate — tracked separately for the Gateway, the monolith, and each microservice.
- Deployment Frequency — should increase as services are extracted; each service deploys independently.
- Mean Time to Recovery (MTTR) — incidents in an isolated service must not affect the rest of the system.
- Monolith size and complexity — the number of lines of code, classes, and routes in the monolith should decrease over time.
- Contract test coverage — all public APIs of new services are covered by Pact tests.
The Strangler Fig pattern is successful not when the last service has been extracted, but when every intermediate step has delivered measurable value: reduced risk, faster deployments, or simplified scaling of a specific domain.
Conclusion
The Strangler Fig pattern is the safest and most pragmatic way to decompose a PHP monolith. It allows teams to move iteratively, validating every decision in a production environment without putting the overall system's reliability at risk.
The key principles we covered: start with thorough domain mapping, use the API Gateway as the central orchestration element, always implement Shadow Mode before switching traffic, and address database splitting proactively rather than reactively. For PHP and Laravel, this path is especially natural: REST API is the native language of communication, and the tool ecosystem — Docker, Kubernetes, Traefik, Pact — fully covers all operational needs.
Extracting the auth service in Go is just the first step. The notification service, billing, and search will follow. Each extracted service makes the monolith a little smaller and the team a little more autonomous. That is the power of the Strangler Fig pattern: not a revolution, but an evolution with measurable results at every stage.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →