Escape from FOSUserBundle

Sometimes even great open source packages just fall off the maintenance wagon. FOSUserBundle, a historic go-to for the Symfony community, is sadly looking like one of those cases right now. Behind on updates for modern, long-term support Symfony versions, with calls to reconsider its pervasiveness going back at least as far as 2016, it’s no wonder there are questions about a migration path.

Unfortunately many of the problems with the bundle’s design – being very tightly coupled to the parent framework while not necessarily keeping up with its best practice – also preclude writing a good general purpose migration guide. But I’m hoping by documenting the biggest changes I had to make for my project to get out of FOSUserPrison, I can slightly reduce the time it takes to migrate some other projects.

Most Symfony packages I’m using in code examples below are v4.4 – with one or two bumped to v5.2 where this didn’t cause compatibility issues.

Extend your user model

This was quite a painful one to do by hand, and it makes me wonder if it would be a valuable exercise to publish a tiny package that provides just the base model pieces from FOSUserBundle so that migrating projects could extend that class. Sadly this thought came after I was done and I have not found anybody else doing this yet, so for now here are the main tweaks I made directly to my User entity:

  1. Added every field, plus Doctrine annotation metadata from the FOS UserInterface class, User class and metadata definition. And getters and fluid setters for every field which had them in the FOS repo. Yes, this is long.
  2. Made the class implement all of UserInterface, \Serializable, EquatableInterface. The last of these is seemingly critical and sneaky and took hours to discover! Missing it broke my app in a subtle and super annoying way: users were authenticated successfully, but then tokens/identities were lost immediately, by the time the redirect to the authenticated page happened. So if you see only anonymous tokens when everything else about the login seems fine, double check this. I’m not totally clear why I need this with my current guard security config, but I do! And I missed it despite cross referencing other implementations, because it’s not part of the standard Maker bundle’s suggested entity, and it’s buried down in some PHP-version-dependent code in the original FOS source – helpfully enabling me to miss it despite checking the same class for its interfaces. 🤦‍♂️ Props to this neglected StackOverflow answer for eventually nudging me in the right direction.
  3. Used the Maker bundle’s make:user to create some temporary entity code from which I could just crib other things I should be doing to align with modern Symfony conventions. The key outcomes being that I should:
    1. implement Symfony\Component\Security\Core\User\UserInterface
    2. implement getRoles() closer to the Symfony way than to FOS’s, since I don’t use the latter’s group » role mapping logic

The most useful copy/paste is probably what I ended up with for role handling, if like me you don’t use FOS groups to dictate the allowed roles. It’s basically a merge of the FOS and Symfony Maker approaches:

    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = static::ROLE_DEFAULT;

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = [];

        foreach ($roles as $role) {
            $this->addRole($role);
        }

        return $this;
    }

    public function addRole($role)
    {
        if ($role === static::ROLE_DEFAULT) {
            return $this;
        }

        if (!in_array($role, $this->roles, true)) {
            $this->roles[] = $role;
        }

        return $this;
    }

Replace logInUser() hacks

Not all FOSUserBundle projects by any means will call this helper method, but I did because my mobile app authenticates requests with a stateless JWT instead of the built-in forms. After validating a token, previously I’d call this method to tell the bundle to treat the request as authenticated.

Fortunately the fix is a pretty simple copy from the bundle’s implementation and my calls to the method were already abstracted so as to happen in only one place.

The change was to replace

$this->loginManager->logInUser('main', $user);

with

$this->userChecker->checkPreAuth($user);
// 'main' must match firewall name in security.yaml
$token = $this->createToken('main', $user);
$this->sessionAuthenticationStrategy->onAuthentication($request, $token);
$this->tokenStorage->setToken($token);

Use a maintained bundle for password resets

I adopted symfonycasts/reset-password-bundle for password resets.

It has a recipe to work with the Maker bundle and its readme is a good guide to getting the bits you need in place.

Migrate remaining forms & controllers

I next copied form types that were previously held in the bundle, e.g. ChangePasswordFormType and friends, to my own MyApp\Form\Type namespace.

I migrated overridding controllers I’d previously maintained to modify FOS behaviour, deleting the whole MyApp\Controller\FOSUserBundle namespace. Instead, those controllers now live in the top level of MyApp\Controllers and they extend only the generic Symfony\Bundle\FrameworkBundle\Controller\AbstractController. The last steps to make this work were typically just copy/pasting any methods that I had not overridden from the FOS base class, so that they were defined directly in my app and the last PHP dependency on the bundle was gone.

Finally, I took out the route files which loaded actions from the bundle, and replaced them with counterparts that use my app code directly in routes.yaml.

Easy!

OK, not really! But worth it. This dependency had felt like a time bomb for some time, and I’m not sad to be rid of it.

I hope I haven’t missed anything crucial and that this post is of some help in your migration journey. Unfortunately everyone’s path will inevitably look different, but I hope yours is a bit smoother having read this!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.