A Migration Tale: from our custom PHP framework to Symfony

Key points and strategy to migrate from a proprietary framework to Symfony, discussing routing, service injection, and database entities.


3 min read

โ€‹โ€‹We decided to code our own PHP framework when founding AssoConnect many years ago. That was a great yet challenging way to learn how many critical parts of a framework work ๐Ÿค“

But maintaining it was also too intense so we decided to migrate to the Symfony framework backed by a strong community.

We aimed to get as many end-to-end legacy-free flows as possible to lay down strong foundations to build modern code ASAP.

This means Symfony must be exposed and visible to the world: it can't start as a small piece within our old framework.

So we first changed the routing: Symfony will be from now on the entry point

  • If a modern controller supports the requested route, then it serves it

  • If not, then the request is routed to our legacy framework which handles it

# routes.yaml
    path: /{path}
    controller: App\Controller\LegacyController::index

Running php bin/console debug:router shows that the routing rule comes last โœ…

Then we established a base rule for dependency injection:

  • โœ… Old code can depend on modern code

  • โŒ Modern code cannot depend on old code

The container is passed along the Request object so old code can access modern dependencies through public aliases.

This makes it possible to have an end-to-end flow without a line of old code ๐Ÿ˜Ž

And old code doesn't stain the new code โœ…

It also designs a strategy to migrate the codebase:

  • Draw the dependency tree

  • Start with the leaves until there is no more old code

Our proprietary ORM uses entities close to Doctrine 1 ones (they persist themselves) which is very different from the Doctrine 2 logic (the ORM is in charge of persisting entities).

Our entities also have built-in lifecycle events and validation.

So migrating overnight wasn't an option ๐ŸŸฅ

We set a second rule for migrating entities in line with the main plan:

  • โœ… Old tables can sync (create, update, delete) modern tables

  • โŒ Modern tables cannot sync back to old tables

โš ๏ธ This creates a strong constraint: a modern table (and the related entity) is read-only as long as the old table exists.

It wasn't obvious at first sight but it actually makes sense when migrating data

  • You first expose a new interface (modern tables and entities).

  • Then you migrate read services.

  • Then you migrate write services (you should only have a few ones anyway or you have duplicate code or bad responsibilities segregation).

We are still migrating and our strategy is sometimes frustrating: rules may require us to migrate a part that is not a business priority.

But it helps to keep a clean modern codebase ๐Ÿ’ซ

And it has a rewarding upside: the more code you migrate, the more code turns migratable ๐Ÿคฉ