DEV Community

Cover image for Migrating Laravel to Symfony Without Rewriting Your Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Migrating Laravel to Symfony Without Rewriting Your Domain


You've been handed the ticket everyone dreads: move the app off Laravel and onto Symfony. Maybe the team standardized on Symfony. Maybe a Messenger-and-Doctrine shop acquired you. Maybe someone decided Eloquent's global scopes had cost enough Friday nights.

The estimate comes back in quarters. Someone quotes the phrase "big rewrite" and the room goes quiet, because everyone remembers the last big rewrite.

Here is the question that decides whether this is a quarter or a year: how much of your business logic imports the framework?

If your PlaceOrder logic reaches into request(), calls Order::create() on an Eloquent model, and opens a transaction with DB::transaction(), then the framework is the application and you are rewriting the application. If your business rules sit in plain PHP classes that never heard of Laravel, then the migration is an adapter swap, and adapter swaps ship one route at a time.

What the framework actually owns

Strip a typical Laravel app down and you find three categories of code.

The first is your domain and use cases: the rules about what an order is, what placing one means, when it can be cancelled. This is the part the business pays for. It should not import a framework at all.

The second is glue that translates the outside world into calls on that logic: controllers, form requests, Eloquent models, queue jobs, service providers. This is framework-specific by definition.

The third is infrastructure your code talks to through interfaces: the database, the queue, the mailer, the payment gateway.

A framework migration only touches the second category. The trouble is that most Laravel apps let the first and second categories bleed into each other until you cannot tell them apart. The migration cost is almost entirely the cost of separating them. Do that separation first, while still on Laravel, and Symfony becomes a weekend of wiring.

The version that hurts to migrate

Here is the shape that makes the estimate balloon. The rules live inside the controller, the model is an ActiveRecord that talks to the database directly, and the framework is threaded through every line.

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;

final class OrderController
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'customer_id' => 'required|uuid',
            'items' => 'required|array|min:1',
        ]);

        $order = Order::create([
            'customer_id' => $data['customer_id'],
            'status' => 'placed',
        ]);

        foreach ($data['items'] as $item) {
            $order->items()->create($item);
        }

        Mail::to($order->customer)->send(new OrderPlaced($order));

        return response()->json($order, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Every rule that matters is trapped here. "An order needs at least one item" is a validation string. The total is never computed. The mail send is inline. To move this to Symfony you rewrite the whole method, and the rewrite is risky because the rules were never named anywhere you can test them in isolation.

Move the rules into plain PHP first

Before touching Symfony, pull the logic out of the controller and into a use case that depends on interfaces, not on Laravel. This step runs entirely on your current Laravel app, in production, with tests, before the migration starts.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\OrderRepository;
use App\Application\Port\EventBus;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private EventBus $events,
    ) {}

    public function execute(PlaceOrderInput $in): PlaceOrderOutput
    {
        $order = Order::place(
            OrderId::generate(),
            $in->customerId,
            $in->items,
        );

        $this->orders->save($order);
        $this->events->publishAll($order->releaseEvents());

        return PlaceOrderOutput::fromOrder($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

No Request, no Eloquent, no DB::, no Mail::. OrderRepository and EventBus are interfaces you define in your own Application\Port namespace. Order::place holds the "at least one item" rule and computes the total. This file will compile and pass its unit tests on Laravel today and on Symfony next month, unchanged, because it does not know which one it runs under.

The Laravel controller shrinks to a translator:

<?php

namespace App\Http\Controllers;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Illuminate\Http\Request;

final class OrderController
{
    public function __construct(
        private readonly PlaceOrder $placeOrder,
    ) {}

    public function store(Request $request)
    {
        $input = PlaceOrderInput::fromArray(
            $request->all(),
        );

        $output = $this->placeOrder->execute($input);

        return response()->json($output, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Parse, call, serialize. That is all a controller should ever do, and it is the only part you throw away when Symfony arrives.

What moves and what stays

Once the split exists, the migration is a table you can read at a glance.

Stays, byte for byte:

  • App\Domain\* — entities, value objects, domain events. Pure PHP.
  • App\Application\* — use cases, ports, DTOs. Depends on interfaces only.
  • Your unit test suite for both of the above.

Gets rewritten as thin adapters:

  • Controllers → Symfony controllers with #[Route] attributes.
  • Form requests → the same parsing, done in the controller or a DTO factory.
  • Queue jobs → Symfony Messenger message handlers.
  • Service providers → services.yaml bindings.
  • Eloquent models used for persistence → Doctrine repositories behind the same OrderRepository interface.

The one that needs care is persistence. If you used Eloquent models as your domain objects, they carry ActiveRecord habits your domain should not inherit. Keep the Eloquent model as a persistence detail behind your repository interface, map it to the domain entity, and swap it for a Doctrine implementation of the same interface when you move. The use case never notices, because it only ever saw OrderRepository.

The seam that lets you ship incrementally

You do not flip the whole app in one deploy. You run both frameworks behind the same domain, and move routes across one group at a time. This is the strangler-fig pattern, and Martin Fowler's write-up is still the clearest description of it.

The mechanics: put a reverse proxy in front of both apps. New Symfony service handles /api/orders. Old Laravel app handles everything else. Nginx routes by path.

location /api/orders {
    proxy_pass http://symfony_app;
}

location / {
    proxy_pass http://laravel_app;
}
Enter fullscreen mode Exit fullscreen mode

Both apps load the same App\Domain and App\Application code as a shared Composer package or a shared path. When a request for /api/orders lands, Symfony's controller builds a PlaceOrderInput, calls the exact same PlaceOrder use case the Laravel controller called yesterday, and returns the same JSON. The customer sees nothing. Your monitoring sees one more route served by a different process.

Move a route group, watch the dashboards for a day, move the next. If a group misbehaves, the Nginx change to route it back to Laravel is one line and one reload. There is no big-bang cutover to be awake for at 2am.

Wiring the same use case under Symfony

The Symfony side of the seam is a controller and a services.yaml binding. The controller mirrors the Laravel one because both are translators for the same core.

<?php

declare(strict_types=1);

namespace App\Ui\Http\Controller;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class PlaceOrderController
{
    public function __construct(
        private readonly PlaceOrder $placeOrder,
    ) {}

    #[Route('/api/orders', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $payload = json_decode(
            $request->getContent(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        $output = $this->placeOrder->execute(
            PlaceOrderInput::fromArray($payload),
        );

        return new JsonResponse($output, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

The binding tells Symfony's container which adapter fills each port:

services:
  App\Application\Port\OrderRepository:
    class: App\Infrastructure\Doctrine\DoctrineOrderRepository

  App\Application\Port\EventBus:
    class: App\Infrastructure\Messenger\MessengerEventBus
Enter fullscreen mode Exit fullscreen mode

In Laravel the same wiring was three $this->app->bind(...) calls in a service provider. Same graph, different config file. The use case constructor never changed.

Where the transaction lives

One trap catches people mid-migration. In Laravel the habit is DB::transaction(fn () => ...) wrapped around the controller body. If that call sits inside your use case, you dragged Laravel's facade into your domain and it will not exist under Symfony.

Keep the transaction at the edge, owned by the framework you are running. Wrap the use case in a small decorator that opens and commits through whatever the current framework offers: DB::transaction on Laravel, the Doctrine EntityManager on Symfony. The use case just calls $this->orders->save($order) and trusts that someone outside it drew the transaction boundary. That decorator is one of the adapters you rewrite. The use case is not.

The order of operations

The migration that ships looks like this, and only the last two steps involve Symfony at all:

  1. Extract use cases from controllers. Define ports as interfaces. Stay on Laravel.
  2. Move business rules into pure domain classes with named factories. Stay on Laravel.
  3. Write unit tests against the use cases with in-memory adapters. Stay on Laravel.
  4. Stand up the Symfony app sharing the same Domain and Application code.
  5. Route one endpoint group to Symfony behind the proxy. Watch. Repeat.

Steps 1 through 3 are the real work, and they pay for themselves even if the Symfony migration gets cancelled. You end up with a testable core either way. Steps 4 and 5 are the part the ticket was actually about, and by the time you reach them the risk is gone, because the code that matters already ran in production untouched.

A framework migration feels enormous because most apps make the framework load-bearing. Take the load off it first. Then swapping Laravel for Symfony is what it should always have been: rewriting the doors, keeping the house.

The whole point of a hexagonal layout is that the framework sits at the edge, reachable only through ports your domain defines. Keep persistence, HTTP, and messaging out at that edge and the choice between Laravel and Symfony stops being an architecture decision — it becomes a config file. That inversion, and the migration playbook for apps that grew the other way, is what Decoupled PHP walks through slice by slice.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)