- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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);
}
}
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);
}
}
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);
}
}
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.yamlbindings. - Eloquent models used for persistence → Doctrine repositories behind the same
OrderRepositoryinterface.
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;
}
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);
}
}
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
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:
- Extract use cases from controllers. Define ports as interfaces. Stay on Laravel.
- Move business rules into pure domain classes with named factories. Stay on Laravel.
- Write unit tests against the use cases with in-memory adapters. Stay on Laravel.
- Stand up the Symfony app sharing the same
DomainandApplicationcode. - 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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)