From Big Ball of Mud to Event-Driven: AI-Assisted PHP Monolith Decomposition

Posted on April 20, 2026

Table of Contents

Digging Through the Mud

There comes a point in every engineer’s life where they have to work on one of these.

A 10+ year old PHP monolith. Procedural. Functions next to HTML. Lightly seasoned with a framework that isn’t maintained anymore. Controllers spanning thousands of lines. Functions mutating global state like it’s a feature. No tests. No documentation. Just tribal knowledge and quiet fear.

It works, mostly. Until it doesn’t.

At that point, the real problem isn’t fixing it, it’s understanding it. The system isn’t just code, it’s geological. Layers of decisions, business logic and hot-fixes, compressed tightly into something dense and fragile.

Good luck trying to maintain a timeline. Engineers have to manually trace execution paths, annotate side effects, reverse engineer intent. Weeks turn into months before any meaningful change happens.

That approach doesn’t scale.

We now have access to something fundamentally different: architectural AI. Not as a code generation tool. As an excavation tool.

This post aims to walk through a practical approach to using LLMs, both local and hosted (Gemini, Claude), to safely decommission a legacy PHP monolith and migrate to a modern, event-driven architecture using Laravel and AWS.

The Ethics of Excavation (Privacy First)

Before getting started with an LLM, you hit a wall. This isn’t a technical wall; it’s a legal one.

If your system touches payments or user data, you’re dealing with:

  • GDPR constraints on data processing
  • PCI-DSS requirements around cardholder data
  • Contractual obligations around source code handling

Blindly passing source code to a third-party API is a big no-no.

This is where local models come in clutch. They stop being a curiosity and become infrastructure.

In practice, I’ve used a mix of:

  • Llama 3
  • Qwen 2.5, 3 and 3.5
  • DeepSeek
  • Gemma
  • Custom Qwen 3.5 models set up with Claude 4.5 Opus-style inference layered in

The last set up quickly became my go to for coding tasks. With well defined prompts, rules and “skills” (especially when paired with agentic tools like Claude Code), the output becomes consistently usable, rather than speculative.

Taking it further with Local RAG

Because I’m running this assistant locally, I’ve augmented it with a RAG (Retrieval-Augmented Generation) pipeline indexed against the Laravel 12 documentation, which I will soon update to Laravel 13. While hosted models often lag behind framework releases, my local assistant has a “live” understanding of the latest features - like the newest event discovery patterns - allowing me to map legacy logic directly into the most modern standards of the framework.

Stabilising the Excavation Site

Without that structure though, local models can start to drift. They hallucinate, lose context, or make bad assumptions. But that’s not unique to local models. Even the most premium cloud models behave the same way over long sessions. The difference though, is that locally, you control the constraints. Hallucinating too much? Lower the temperature to reduce randomness. Forgetting what you’ve told it a lot? Increase it’s context window. These are settings you can’t control on the cloud models. By moving from raw, chat-based interaction to agentic workflows and deterministic constraints, we shift the model from a ‘speculative writer’ to a ‘precision tool’ for system discovery.

The Trade-off: Speed vs Control

Running local models uses your graphics card’s memory, or shared memory in the case of SoC computers like Mac Studios. I personally run it off my graphics card on my Linux machine. However, my 12GB graphics card introduces a very real constraint: latency.

When comparing my setup to cloud models:

  • Responses are slower. Much slower.
  • Large context windows aren’t possible.
  • Iteration cycles take longer.

But that’s a trade I’m okay with. Slower responses in exchange for zero data leaving my environment. For individuals like myself, the current hardware market limits what we can do locally. For companies however, it’s an infrastructure decision:

  • Larger GPUs
  • Dedicated inference machines (e.g Mac studios or on-prem clusters)

Even then, local will rarely beat cloud on raw speed.

But speed isn’t always the bottleneck. Trust is.

Mapping the Maze (Context Retrieval)

PHP has a bad rep. But legacy systems don’t fail because they’re written in PHP. They fail because no one understands them anymore.

A typical example:

  • A 2,000 line controller method
  • Interwoven validation, DB queries and side effects
  • Hidden dependencies on global or session state
  • Multiple execution paths based on loosely defined flags

Having to map out a system like this manually is slow and error-prone.

With LLMs, you can turn this into a structured discovery process.

For example, in a previous system I worked on, report generation had grown organically. Each client variation required a new method, often duplicating large parts of the logic just to tweak formatting or combine fields. Over time, this resulted in dozens of near-identical report functions spread across the codebase.

Technique: Structured Ingestion + Targeted Prompts

Instead of asking “what does this do?”, you extract artefacts.

  1. Logic Flow Decomposition

    • Break functions into discrete steps
    • Identify branching conditions
    • Highlight side effects (DB writes, external calls, etc.)
  2. Dependency Mapping

    • List all invoked functions and services
    • Surface implicit dependencies (global, config, session)
    • Identify breakpoints if extracted
  3. Data Flow Analysis

    • Trace inputs to outputs
    • Identify assumptions about data shape and state

The result isn’t just an explanation; it’s structure:

  • Execution flows you can reason about
  • Dependency graphs you can plan around
  • Boundaries you can extract safely

At this point, the model isn’t so much a chatbot; it’s behaving more like a reverse engineering assistant.

The Win

The “understanding phase” collapses.

What used to take:

  • Days of stepping through code
  • White-boarding flows
  • Cross-referencing files

Becomes:

  • Hours of structured prompting and verification

The most important part is discipline:

  • Ask
  • Verify against source
  • Refine
  • Repeat

The model should accelerate you. It shouldn’t replace your thinking.

Pro-Tip: The “Logic Extraction” Prompt

To ensure a 2,000-line procedural controller is fully understood, I use a structured prompt that forces the LLM to act as a Principal Architect. This turns a “black box” into a clear set of engineering requirements:

Analyse the following legacy PHP code and identify:
1. Core Business Logic: What is the primary intent?
2. Side Effects: List all database writes and external API calls.
3. Hidden Dependencies: Identify calls to global constants or static methods.
4. Contract Definition: Suggest a modern DTO (Data Transfer Object) structure.

The Strangler Fig Pattern + AI Generation

Now that you understand the system, the next challenge is replacing it without breaking production.

The Strangler Fig Pattern works brilliantly when breaking up a monolith into a microservice architecture.

The pattern took inspiration from nature. In nature, the Strangler Fig is an epiphytic tree that grows on another tree by wrapping it’s roots around it. As it grows, it constricts the host tree’s trunk, eventually replacing it with it’s own canopy.

That’s exactly the approach this pattern takes:

  1. Create a thin wrapper, such as a reverse proxy or API gateway, that initially routes all requests to the monolith.
  2. Extract a small feature from the monolith and create a new microservice to replicate the functionality of it.
  3. Gradually route traffic to the new service (e.g route 5% traffic, then 25%, then 50% etc). Include monitoring and logging so you can check for errors or performance issues before routing more traffic.
  4. Rinse and repeat step 2 and 3 until the monolith has been made obsolete.

AI accelerates step 2, the build side of the refactor.

This is what the transition actually looks like in motion:

PlantUML diagram

Routing traffic via the API Gateway was straightforward, though handling the shared database state between the legacy monolith and the new microservice introduced an entirely different set of data gravity challenges that are beyond the scope of this post.

Generating the “New World”

Once a feature has been identified, and it’s logic has been isolated, LLMs are extremely effective at scaffolding it’s replacement.

SQL -> Laravel Abstractions

Legacy PHP systems tend to treat SQL as an all-in-one tool:

  • Queries embedded directly in controllers
  • Business logic intertwined with data access
  • Repeated query patterns with slight variations
  • No clear boundary between read and write concerns

At first glance, converting this to Laravel looks mechanical. It isn’t.

In the reporting system mentioned earlier, this showed up as multiple queries duplicated across different report methods, each slightly modified depending on the client’s requirements.

The Naive Migration (What Not to Do)

You can take raw SQL and wrap it like this:

DB::select("SELECT * FROM users WHERE email = ?", [$email]);

Technically correct. Architecturally… unchanged.

You’ve just moved the mud into a new container.

The Real Goal: Extract Intent

The migration is an opportunity to separate what the system does from how it queries data.

A more deliberate transformation looks like:

Step 1: Isolate Query Responsibility

Move data access into a dedicated layer:

class UserRepository
{
    public function findByEmail(string $email): ?User
    {
        return User::where('email', $email)->first();
    }
}

Step 2: Separate Business Logic

class AuthenticateUser
{
    public function execute(string $email, string $password): bool
    {
        $user = $this->users->findByEmail($email);

        if (!$user) {
            return false;
        }

        return Hash::check($password, $user->password);
    }
}

Now the query is no longer the centrepiece. It’s an implementation detail.

Where AI Helps (Usefully)

LLMs are particularly effective at:

  • Translating raw SQL into Query Builder or Eloquent
  • Identifying repeated query patterns across files
  • Suggesting extraction into reusable methods
  • Highlighting where logic and data access are tightly coupled

More interestingly, they can spot things like:

  • “This query is reused in 5 places with slight variations”
  • “This join implies a relationship that isn’t modelled”

That’s not just conversion. That’s structural insight.

A Practical Pattern

When working through a legacy file:

  1. Extract SQL queries

  2. Ask the model:

    • “What is the intent of this query?”
    • “What would this look like as a reusable method?”
  3. Refactor into:

    • Repository methods
    • Query scopes
    • Eloquent relationships

Then validate against the original behaviour.

The result:

  • Fewer duplicated queries
  • Clearer domain boundaries
  • Code that reads like intent, not instructions

Schema -> Migrations

Databases in legacy systems often evolve due to developers saying “I need to make x change to the database”. At least on projects I’ve worked on, there tends to be no formal migration files or even a change document.

Laravel already provides tools like schema:dump to extract a database schema into a reusable snapshot. This is useful for bootstrapping and testing, but it doesn’t solve the core problem of legacy databases: lack of structure and intent.

What you get is a reflection of the current state, not a clean design.

This is where AI becomes useful. Instead of just reproducing the schema, you can use it to:

  • “Suggest missing indexes based on how data is queried”
  • “Infer relationships and recommend foreign key constraints”
  • “Highlight inconsistencies in column types and naming”
  • “Translate raw tables into clear, maintainable Laravel migrations”

The goal isn’t to duplicate the database. It’s to reconstruct it with intent.

DTOs and Contracts

If SQL refactoring is about cleaning the inside, DTOs are about defining the edges.

Most legacy systems don’t have clear boundaries. Data flows through arrays, which leads to situations like:

  • Inconsistent keys
  • Mixed types
  • Optional fields that are only “optional” if you know the context

This becomes a major risk during a migration.

The Hidden Problem: Shape Drift

In a legacy monolith, it’s common to see:

  • One function returns user_id
  • Another expects id
  • A third silently accepts both

Everything “works” because everything is loosely coupled through arrays and assumptions.

When you start extracting services, that flexibility becomes fragility.

The Role of DTOs

DTOs force you to make the implicit explicit.

  • What data exists
  • What it’s called
  • What type it is
  • What is required vs optional

Example:

class CreateUserDTO
{
    public function __construct(
        public string $email,
        public string $password,
        public ?string $referralCode = null,
    ) {}
}

Now the boundary is explicit.

Contracts Between Old and New

During a strangler migration, you effectively have two systems:

  • The legacy monolith
  • The new service

They need to agree on:

  • Payload structure
  • Field naming
  • Validation rules

DTOs become the translation layer.

You might have:

  • A mapper from legacy arrays -> DTO
  • A mapper from DTO -> API response

This isolates inconsistency to a single place instead of spreading it across the system.

Where AI Helps (Again, Carefully)

LLMs are useful for:

  • Inferring data shapes from legacy code
  • Generating initial DTO classes
  • Highlighting inconsistent field usage
  • Suggesting required vs optional fields

For example:

  • “This field is always checked before use -> likely required”
  • “This field is nullable in 3 places -> should be optional”

But this is where you need to stay sharp.

AI can infer patterns. It cannot understand business meaning.

The Real Value: Forcing Decisions

DTOs do something subtle but powerful.

They force you to answer questions the legacy system typically avoid:

  • Is this field actually optional?
  • What does this value represent?
  • Should this ever be null?

That’s not just refactoring. That’s domain clarification.

In the reporting system, each method returned slightly different array structures depending on the report type. Some included combined name fields, others split them, and some added extra metadata. There was no consistent shape.

Introducing DTOs forced a decision: what does a “report result” actually look like? Once defined, transformations like combining or splitting fields became explicit steps rather than hidden behaviour.

A Practical Migration Pattern

  1. Extract a piece of functionality
  2. Identify input/output data shapes
  3. Use AI to propose a DTO
  4. Manually validate and refine
  5. Build mappers between:
    • Legacy format <-> DTO
    • DTO <-> New service

Once that’s in place:

  • The new system is stable
  • The legacy system can remain messy
  • The boundary absorbs the chaos

Event-Driven Migration in Practice

A common issue in legacy systems is synchronous, long-running requests:

  • File processing
  • Report generation
  • Heavy data transformations

These often have a negative impact on the application:

  • Block new HTTP requests
  • Cause timeouts
  • Degrade user experience

The fix is fairly straightforward:

  1. Move computationally expensive work into background jobs (queues)
  2. Expose a polling endpoint for progress. Optionally introduce WebSockets for real-time updates.

Polling is the pragmatic choice during a migration. WebSockets improve UX, but come with additional infrastructure considerations that may not be justified early on.

In the case of my reporting system, report generation was also moved into a background worker. Instead of generating reports during the request, the system would dispatch a job and expose a polling endpoint to track progress.

This not only improved reliability but also made it possible to scale report generation independently of the main application.

From an infrastructure perspective:

  • API Gateway can route traffic between old and new systems
  • A shared auth layer avoids users logging into multiple systems
  • EventBridge enables communication between microservices

One of the more useful patterns is introducing event publishing inside the monolith itself. The turning point isn’t extraction, it’s emission. Once the monolith starts publishing events, it stops being a dead-end and becomes a signal source. Other services can react to it immediately, creating a bridge between the old and new architecture without a hard cut-over. This decouples timelines, not just code. New services can evolve independently long before the monolith is removed.

Validation: The “Safe Hands” Layer

This is where migrations quietly fail.

Not during deployment. Not during refactoring. But in subtle behavioural differences that only show up in production.

Legacy systems often have:

  • No tests
  • Undefined edge cases
  • Implicit behaviour relied on by users

AI-Assisted Test Generation

Before moving any logic, you can generate tests from the legacy code itself:

  • Input/output scenarios
  • Edge cases
  • Failure conditions

The workflow looks a little bit like this:

  1. Feed legacy logic into the model
  2. Generate test cases
  3. Implement them against the existing legacy system
  4. Lock in the behaviour

Then, in the newer system:

  1. Rebuild the logic
  2. Run the same test suite
  3. Confirm parity

If both implementations pass the tests, then you have confidence that you have achieved feature parity.

The hardest part of these migrations isn’t building the new system, it’s proving the old and new systems behave the same.

To quickly go back to my report generator example; before replacing the legacy report methods, I generated tests for each variation to lock in existing behaviour. This ensured that the new, configurable reporting system maintained parity with the old outputs, even though the implementation was completely different.

Real-World Gotcha: Framework Assumptions

This is where experience matters more than tooling.

During a migration to Laravel, I hit an issue where:

  • A previously blank field became null
  • Validation behaviour changed silently

The cause was Laravel’s default middleware: ConvertEmptyStringsToNull

It was doing exactly what it was supposed to do. But it didn’t match legacy behaviour.

The fix:

  1. Identify the mismatch
  2. Write a test to lock in the expected behaviour (empty strings, not null)
  3. Remove or adjust the middleware
  4. Ensure test passes

Problem solved.

The lesson isn’t about Laravel. It’s about implicit behaviour.

Frameworks have opinions. Legacy systems have quirks. Your job during a migration is to ensure those don’t collide silently.

From Excavator to Architect

Let’s face it. The role of the Senior Engineer is fundamentally shifting.

It is no longer just about writing code or designing new systems; it’s about navigating the immense complexity of legacy debt with absolute precision.

Legacy monoliths aren’t going away - if anything, they are becoming the primary environment where high-stakes engineering happens.

The difference today is the maturity of our tooling.

By leveraging local LLMs for sensitive analysis and structured prompting for system discovery, we remove the guesswork from modernisation.

We replace manual, error-prone “excavation” with a repeatable, testable methodology. The result isn’t just faster output; it’s a destination system that is observable, testable, event-driven, and evolvable.

The real upgrade isn’t the AI. It’s the engineer who knows how to calibrate it to deliver architectural parity with absolute confidence. That is how we turn “geological” legacy mud into modern, scalable infrastructure.

Ready to start a project? Email me.

Based in the UK | Available for new contract projects immediately (remote).

Built with Gatsby + Tailwind | © 2026 Sam Rook | LinkedIn / GitHub