Migration from Symfony 5.2 to 5.4

In this article, I will describe all the steps I did to update from Symfony 5.2 to 5.4, together with all other packages I use. So these steps are closely linked to my software, but might also be helpful for others.

Major steps

 I started with some major other components before updating Symfony. Here are the main steps I did:

  • Update the required php version to 7.4
  • Update to the highest 5.2 Symfony version
  • Update doctrine behaviors
  • Fix doctrine event subscribers
  • Update liip fixtures bundle
  • Update to Symfony 5.3
  • Update to the Symfony's new security system
  • Remove deprecation warnings
  • Update to Symfony 5.4
  • Remove deprecation warnings

This is quite a long list, but most of this is straight-forward. There is a lot of documentation out there to help you with these steps. I added the links where needed. But now let us start with the steps.

Update php requirement and Symfony 5.2 components

In the file composer.json, change the minimal php version under "require" and under "platform" to 7.4. Then, to make sure that we do the Symfony update step by step, change all Symfony requirements from "^5.0.0" to "5.2.*", to force an update to the last 5.2 release. Doing the update without this would change all packages to 5.4 in one step, which is to much to fix in one step.

After changing the settings, run "composer update" to fetch all new packages.

In this step, I had some failing tests which took some time to figure out. It turned out, that the crawler components was changed to be a bit stricter for form values to be passed. In my code, I passed values to a set of check boxes, and I passed in some tests a value instead of an array, which was OK in previous versions (but of course wrong). In code, this looks like this:

$crawler = $client->request('POST', $url, [
  'member_lists_selection' => [
-    'status' => $this->statusLookup['Member'],
+    'status' => [$this->statusLookup['Member']],
    'email' => 'all',
    'others' => ['directDebit' => 'all'],
  ],

Update the doctrine behaviors package

I use the doctrine behavior package to translate database entities. It adds a database table for an entity in which properties are to be translated and takes care of the translation process. This package has had a major version change, which required to change all entities using it. The changes were straighforward, once you understand the translatable documentation.

In the entity classes, the following changes are needed:

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Gedmo\Mapping\Annotation as Gedmo;
-use Knp\DoctrineBehaviors\Model as ORMBehaviors;
+use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
+use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait;
 
/**
 * AddressType
 * @ORM\Entity(repositoryClass="App\Repository\AddressTypeRepository")
 * @ORM\HasLifecycleCallbacks()
 */
-class AddressType implements ValueListInterface, EntityLoggerInterface, EntityTranslationInterface
+class AddressType implements ValueListInterface, EntityLoggerInterface, EntityTranslationInterface, TranslatableInterface
{
-    use ORMBehaviors\Translatable\Translatable;
+    use TranslatableTrait;
 
    /**
     * @ORM\Id

Here the TranslatableInterface is added, as well as the TranslatableTrait, which contains all the methods needed by the interface.

And in the translation class, similar changes are needed:

namespace App\Entity;
 
use Doctrine\ORM\Mapping as ORM;
-use Knp\DoctrineBehaviors\Model as ORMBehaviors;
+use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
+use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait;
 
/**
 * Class AddressTypeTranslation
 *
 * @ORM\Entity()
 */
-class AddressTypeTranslation
+class AddressTypeTranslation implements TranslationInterface
{
-    use ORMBehaviors\Translatable\Translation;
+    use TranslationTrait;
+
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue(strategy="AUTO")
+     */
+    private $id;
 
    /**
     * @ORM\Column(type="string", length=255)

So you have to add the TranslationInterface to the class and implement so methods, for which the TranslationTrait is included. In version 1, the index field for the translation object was added by the trait. Now we have to add the index field ourselves.

Repair doctrine event subscribers

Doctrine event subscribers got a new name for their interface. Also it turned out that I added the event subscribers manually in the services.yaml, which was not necessary anymore. I don't know where along the way of this project this was changed in Symfony. During this update "orgy", it became apparent, because now the doctrine event subscribers were called twice, resulting in writing the change log messages for entities twice. The repair of these were very simple:

use App\Service\LogMessageCreator;
-use Doctrine\Common\EventSubscriber;
+use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
+use Doctrine\ORM\Events;
use Doctrine\ORM\ORMException;
 
/**
 *
 * Class ChangeGroupMembershipTypeOnMemberListener
 */
-class ChangeGroupMembershipTypeOnMemberSubscriber implements EventSubscriber
+class ChangeGroupMembershipTypeOnMemberSubscriber implements EventSubscriberInterface
{
    /** @var LogMessageCreator */
    private $logMessageCreator;

    /**
     * @inheritdoc
     *
     * @return array
     */
    public function getSubscribedEvents()
    {
-       return ['onFlush'];
+       return [Events::onFlush];
    }

And in the services.yaml, the manual entries for the doctrine event subscribers have to be removed:

-    app.doctrine.change_group_membership_type_on_member_listener:
-        class: App\Doctrine\ChangeGroupMembershipTypeOnMemberSubscriber
-        tags:
-            - { name: doctrine.event_subscriber }

Update liip fixtures bundle

I use the liip test fixture bundle to handle loading fixtures and reloading the mysql database during tests. When updating to the current version, some settings have to be changed, as described in the documentation of the bundle.

In the file config/packages/test/liip_test_fixtures.yaml, we have to change the setting for using sqlite and add some settings:

liip_test_fixtures:
+   keep_database_and_schema: false
+   cache_metadata: true
    cache_db:
-       sqlite: liip_test_fixtures.services_database_backup.sqlite
+       sqlite: 'Liip\TestFixturesBundle\Services\DatabaseBackup\SqliteDatabaseBackup'

 Then I had to change most of the test classes, since I loaded the fixtures before the kernel was started, which is now no longer allowed:

    public function setUp(): void
     {
-        $this->loadAllFixtures();
         parent::setUp();
+        $this->loadAllFixtures();
     }

 In my tests, I have some methods to make working with the tests easier. Here also some changes are needed (I have these methods in a trait, to be able to use them in my TestCase.php as well as in my WebTestCase.php):

use Doctrine\Common\DataFixtures\ReferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Symfony\Component\Translation\Translator;
-use Liip\TestFixturesBundle\Test\FixturesTrait;
+use Liip\TestFixturesBundle\Services\DatabaseToolCollection;
+use Liip\TestFixturesBundle\Services\DatabaseTools\AbstractDatabaseTool;

/**
 * This bundles all methods needed for both integration and functional tests, i.e. for the TestCase and the WebTestCase.
  */
 trait TestCaseTrait
 {
-    use FixturesTrait;
     protected static string $logDir;
     protected ReferenceRepository $referenceRepository;
+    private AbstractDatabaseTool $databaseTool;
 
 
     /**
      * Do some initialisations which can be used in the setup methods
      */
     public function setupMutual(): void
     {
         $this->useForeignKeys(true);
+        $this->databaseTool = static::getContainer()->get(DatabaseToolCollection::class)->get();
     }
 
 
+    /**
+     * Use this method to load certain fixture classes.
+     *
+     * @param array $fixtures
+     *
+     * @return void
+     */
+    protected function loadFixtures(array $fixtures)
+    {
+        $this->useForeignKeys(false);
+        $this->databaseTool->loadFixtures($fixtures);
+        $this->useForeignKeys(true);
+    }
 
 
     /**
      * This loads all the fixtures defined in the project, including ordering them, e.g. by the
      * DependentFixtureInterface
      *
      * @param string[] $groups Filter fixtures by group name.
      *
      * @return AbstractExecutor|null
      */
     protected function loadAllFixtures(array $groups = ['default']): ?AbstractExecutor
     {
-        /** @var SymfonyFixturesLoader $loader */
-        $loader = $this->getContainer()->get('doctrine.fixtures.loader');
-        $fixtures = $loader->getFixtures($groups);
-        $fixtureClasses = [];
-        foreach ($fixtures as $fixture) {
-            $fixtureClasses[] = get_class($fixture);
-        }
-
         $this->useForeignKeys(false);
-        $fixtures = $this->loadFixtures($fixtureClasses);
+        $fixtures = $this->databaseTool->loadAllFixtures($groups);
         $this->referenceRepository = $fixtures->getReferenceRepository();
         $this->useForeignKeys(true);

         return $fixtures;
     }
 

The loadAllFixtures method, which I implemented in my helper functions, is now included in the bundle (I contributed this to the bundle some time ago, so having the same structure is no coincidence). I found out, that is important for the sqlite dumping and reloading the fixtures, to disable foreign key checks. Since I rely on some doctrine/sqlite logic, for example for the cascading removal of entries, I enable the foreign key checks by default, which prevents working with the sqlite fixtures feature in this bundle.

Update to Symfony 5.3

Updating the Symfony 5.3 turned out to be quite easy. I changed all symfony settings in the composer.json from "5.2.*" to "5.3.*" and called composer update. The only thing that stopped working was my system to do integration tests in different languages. Before I had the following method in my WebTestCase.php:

/**
 * With this method the locale in the session object is changed, which changes the the GUI language on the next
 * page request.
 *
 * @param string $locale
 */
protected function setGuiLanguage(string $locale)
{
  $session = $this->containers->get('session');
  $session->set('_locale', $locale);
  $session->save();
}

This would set the locale in the session, which is read by the LocaleListener to set the locale of the request. This didn't work anymore in Symfony 5.3. Even after long debugging I couldn't really figure out what was going on. Since this version, the session is no longer in the services container (since it is a data object and not a service, so that makes sense), as described here. But this didn't only trigger a deprecation warning (which would have been OK), but it also somehow reloaded the container, so my setting the language in the session suddenly did not reach the LocaleListener anymore: there were TWO versions of the session object. Since session handling had to be change anyway, I created a new way to set the gui language in test, by passing the locale as a parameter to the client request. In the tests, this looks like this (here an example from the file MemberClubDataControllerTest.php):

         // Check the order of the committee memberships
         // The language must be switched to German, because in the English fields are ordered correctly by their
         // definition
-        $this->setGuiLanguage('de');
-        $crawler = $this->getMyClient()->request('GET', "/member/edit_club_data/".$this->member1->getId());
+        $crawler = $this->getMyClient()->request('GET', "/member/edit_club_data/".$this->member1->getId(), ['_locale' => 'de']);

For this to work, a change in the LocaleListener was needed, to read this parameter from the requests get parameter:

-        // try to see if the locale has been set as a _locale routing parameter
-        if ($locale = $request->attributes->get('_locale')) {
-            $request->getSession()->set('_locale', $locale);
-        } else {
-            // if no explicit locale has been set on this request, use one from the session
-            $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
-        }
+        $locale =
+            // The locale can come from a routing paramenter,
+            $request->attributes->get('_locale') ??
+            // passed as get parameter (used for tests),
+            $request->query->get('_locale') ??
+            // from previously settings it in the session,
+            $request->getSession()->get('_locale') ??
+            // or from the default locale
+            $this->defaultLocale;
+        $request->getSession()->set('_locale', $locale);
+        $request->setLocale($locale);

 Use the new security system

 One of the major changes in Symfony 5,3 is the new security system. There is a lot of documentation on this new systems [1, 2, 3], which can be confusing. What helped my a lot were the videos and documentation by symfonycasts on this topic. The first sections offer the videos for free, later on you need a subscription. But even without that, you can read the video transcript.

Here is a list of the steps to do:

  • Change the settings in the security.yaml (shown below)
  • Change the UserPasswordEncoderInterface to UserPasswordHasherInterface, including renaming all the methods needed to hash or check the password.
  • Change the LoginFormAuthenticator (shown below)
  • Move features from some listeners to the LoginFormAuthenticator, e.g. setting the user locale and writing log files.

security.yaml:

 security:
-    encoders:
-        App\Entity\MemberEntry:
-            algorithm: auto
+    enable_authenticator_manager: true
+    password_hashers:
+        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
 
     providers:
-        user_db:
+        app_user_provider:
             entity:
                 class: App\Entity\MemberEntry
                 property: username
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
-           anonymous: true
            lazy: true
-           guard:
-               authenticator:
-                   - App\Security\LoginFormAuthenticator
-
-           logout:
-               path: security_logout
-               handlers:
-                   - App\Security\LogoutListener
+           provider: app_user_provider
+           custom_authenticator: App\Security\LoginFormAuthenticator
+           logout: true

Some minor changes are needed in the security.yaml under config/packages/test:

     # This reduces the time needed for encrypting the password, which speeds up the unit tests by a factor of 2-4 times.
-    encoders:
-        App\Entity\MemberEntry:
-            algorithm: auto
+    password_hashers:
+        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
+            algorithm: 'auto'
             cost:      4

 The biggest change is in the LoginFormAuthenticator:

 namespace App\Security;
 
-use App\Entity\MemberEntry;
-use App\Form\LoginForm;
+use App\Service\LogMessageCreator;
 use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Form\FormFactoryInterface;
+use Exception;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\RouterInterface;
 use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
 use Symfony\Component\Security\Core\Exception\AuthenticationException;
-use Symfony\Component\Security\Core\User\UserInterface;
-use Symfony\Component\Security\Core\User\UserProviderInterface;
-use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
 use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
+use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
+use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
+use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
 
 /**
  * Class LoginFormAuthenticator
  */
-class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
+class LoginFormAuthenticator extends AbstractAuthenticator
 {
-    /** @var FormFactoryInterface  */
-    private $formFactory;
-    /** @var EntityManagerInterface  */
-    private $entityManager;
-    /** @var RouterInterface  */
-    private $router;
-    /** @var UserPasswordEncoderInterface  */
-    private $passwordEncoder;
+    private RouterInterface        $router;
+    private LogMessageCreator      $logMessageCreator;
+    private EntityManagerInterface $entityManager;
 
 
     /**
      * LoginFormAuthenticator constructor.
-     * @param FormFactoryInterface         $formFactory
-     * @param EntityManagerInterface       $entityManager
-     * @param RouterInterface              $router
-     * @param UserPasswordEncoderInterface $passwordEncoder
+     *
+     * @param RouterInterface        $router
+     * @param LogMessageCreator      $logMessageCreator
+     * @param EntityManagerInterface $entityManager
      */
-    public function __construct(FormFactoryInterface $formFactory, EntityManagerInterface $entityManager, RouterInterface $router, UserPasswordEncoderInterface $passwordEncoder)
+    public function __construct(RouterInterface $router, LogMessageCreator $logMessageCreator, EntityManagerInterface $entityManager)
     {
-        $this->formFactory = $formFactory;
-        $this->entityManager = $entityManager;
         $this->router = $router;
-        $this->passwordEncoder = $passwordEncoder;
+        $this->logMessageCreator = $logMessageCreator;
+        $this->entityManager = $entityManager;
     }
 
 
-    /**
-     * @param mixed         $credentials
-     * @param UserInterface $user
-     *
-     * @return bool
-     *
-     * @throws AuthenticationException
-     */
-    public function checkCredentials($credentials, UserInterface $user)
-    {
-        $password = $credentials['_password'];
-
-        if ($this->passwordEncoder->isPasswordValid($user, $password)) {
-            return (true);
-        }
-
-        // On failure, throw the user name so it can be used in the log message in LoginListener:onLoginFailure
-        throw new AuthenticationException($user->getUsername());
-    }


+    /**
+     * @param Request $request
+     *
+     * @return bool|null
+     */
+    public function supports(Request $request): ?bool
+    {
+        return $request->get('_route') === 'security_login'
+            && $request->isMethod('POST');
+    }


-    /**
-     * @param mixed                 $credentials
-     * @param UserProviderInterface $userProvider
-     *
-     * @throws AuthenticationException
-     *
-     * @return UserInterface|null
-     *
-     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
-     */
-    public function getUser($credentials, UserProviderInterface $userProvider)
-    {
-        $username = $credentials['_username'];
-
-        $user = $this->entityManager->getRepository(MemberEntry::class)->findOneBy(['username' => $username]);
-        if (!is_null($user)) {
-            return $user;
-        }
-
-        // On failure, throw the user name so it can be used in the log message in LoginListener:onLoginFailure
-        throw new AuthenticationException($username);
-    }


+    /**
+     * @param Request $request
+     *
+     * @return PassportInterface
+     */
+    public function authenticate(Request $request): PassportInterface
+    {
+        $userName = $request->request->all('login_form')['_username'];
+
+        // Pass last given username to the login form in case the login failed. The username is stored in the session
+        // and passed automatically to the username form field.
+        $request->getSession()->set(Security::LAST_USERNAME, $userName);
+
+        return new Passport(
+            new UserBadge($userName),
+            new PasswordCredentials($request->request->all('login_form')['_password']),
+            [
+                new CsrfTokenBadge('login_form', $request->request->all('login_form')['_token']),
+            ]
+        );
+    }

 
     /**
-     * Does the authenticator support the given Request?
-     *
-     * If this returns false, the authenticator will be skipped.
-     *
-     * @param Request $request
-     *
-     * @return bool
-     */
-    public function supports(Request $request)
-    {
-        return $request->attributes->get('_route') === 'security_login'
-            && $request->isMethod('POST');
-    }


    /**
-     * @inheritDoc
+     * @param Request        $request
+     * @param TokenInterface $token
+     * @param string         $firewallName
      *
      * @return Response|null
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
-    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
+    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
     {
-        return new RedirectResponse($this->router->generate('dashboard_homepage'));
-    }
+        // Read the preferred language from the current user and write it to the sesstion
+        $user = $token->getUser();
+        if (null !== $user->getPreferredLanguage()) {
+            $request->getSession()->set('_locale', $user->getPreferredLanguage()->getLocale());
+        }
 
+        try {
+            $this->logMessageCreator->setUser($user);
+            $this->logMessageCreator->createLogEntry('login', 'Login succeeded', $user);
+            $this->entityManager->flush();
+        } catch (Exception $e) {
+            // Left intentionally blank: failing to log cannot be handled otherwise
+        }
+
        return new RedirectResponse($this->router->generate('dashboard_homepage'));
    }

 
-    /**
-     * Return the URL to the login page.
-     *
-     * @return string
-     */
-    protected function getLoginUrl()
-    {
-        return ($this->router->generate('security_login'));
-    }
- }

With the new security system, we can clean up even more. For example can the  UserLocaleListener be removed, since we can now set the users preferred language in the OnAuthenticationSuccess method. For the same reason, we can remove the LoginListener, which until now created the login or log failed log message.

Removing deprecations

Now we can start to remove deprecation messages. In my system, I had about 90 direct and 6700 indirect warning. This sound like a lot of work, but it turned out not to be. Of the 6800 warnings, about 30 fixes are needed. Let's go through them:

allowEmptyString in form validators

 The validator allowEmptyString in forms has to be replaced by two validators, packing them into the quite new validator AtLeastOneOf:

        ->add('plainPassword', RepeatedType::class, [
             'type' => PasswordType::class,
             'invalid_message' => 'The password fields must match.',
             'constraints' => [
-                new Length(['min' => 10, 'allowEmptyString' => true]), // This checks for unicode number of characters, which is what we want!
+                new AtLeastOneOf([
+                    new Length(['min' => 10]), // This checks for unicode number of characters, which is what we want!
+                    new Blank(),
+                ]),
                 new Callback([$this, 'validatePasswordComplexity']),
             ],
         ])

InputBag::get() when reading from request objects

When reading from request objects (and in some other places), we have to change the get() method by the all() method, using the same parameters, which makes this change very easy:

-$formData = $request->request->get('committee_function_form');
+$formData = $request->request->all('committee_function_form');

 Crawler::parents()

-        $this->assertStringNotContainsString('value', $crawler->filter('div.row:contains("Template name (nl)") input')->parents()->html());
+        $this->assertStringNotContainsString('value', $crawler->filter('div.row:contains("Template name (nl)") input')->ancestors()->html());

Session handling

Since the session can no longer be read from the service container (as explained above), we also need to change some classes which interact with sessions.

SessionHandler.php:

 class SessionIdleHandler
 {
-    /** @var SessionInterface */
-    private $session;
 
     /**
      * SessionIdleHandler constructor.
      *
      * @param EntityManagerInterface $entityManager
-     * @param SessionInterface       $session
      * @param TokenStorageInterface  $securityToken
      * @param RouterInterface        $router
      * @param LogMessageCreator      $logMessageCreator
      *
      * @throws Exception
      */
-    public function __construct(EntityManagerInterface $entityManager, SessionInterface $session, TokenStorageInterface $securityToken, RouterInterface $router, LogMessageCreator $logMessageCreator, int $maxIdleTimeSeconds)
+    public function __construct(EntityManagerInterface $entityManager, TokenStorageInterface $securityToken, RouterInterface $router, LogMessageCreator $logMessageCreator, int $maxIdleTimeSeconds)
     {
         if (0 >= $maxIdleTimeSeconds) {
             throw new Exception(sprintf('maxIdleTime must be greater than zero, but "%d" was passed!', $maxIdleTimeSeconds));
         }
 
         $this->entityManager = $entityManager;
-        $this->session = $session;
         $this->securityToken = $securityToken;
         $this->router = $router;
         $this->maxIdleTimeSeconds = $maxIdleTimeSeconds;
     }
 
-    public function onKernelRequest(RequestEvent $event)
+    public function onKernelRequest(RequestEvent $event): void
     {
-        if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) {
+        if (HttpKernelInterface::MAIN_REQUEST !== $event->getRequestType()) {
             return;
         }
 
-        $this->session->start();
-        $lapse = time() - $this->session->getMetadataBag()->getLastUsed();
+        $session = $event->getRequest()->getSession();
+        $session->start();
+        $lapse = time() - $session->getMetadataBag()->getLastUsed();

 MemberPersonalDataController.php:

             // When the currently logged in user changes his language, then also change the gui language by changing the
             // locale in the session. This is otherwise only done on login.
             if ($this->getUser() === $member) {
-                $this->get('session')->set('original_locale', $originalLocale);
+                $request->getSession()->set('original_locale', $originalLocale);
                 if ($originalLocale !== $member->getPreferredLanguage()->getLocale()) {
-                    $this->get('session')->set('_locale', $member->getPreferredLanguage()->getLocale());
+                    $request->getSession()->set('_locale', $member->getPreferredLanguage()->getLocale());
                     $reloadPage = true;
                 }
             }

Fixing the session deprecation warnings

There now are still more than 5000 deprecation warnings after a phpunit run. These all come down to only 3 warnings, which can be fixed really easily:

config/packages/framework.yaml

     # Enables session support. Note that the session will ONLY be started if you read or write from it.
     # Remove or comment this section to explicitly disable session support.
     session:
-        handler_id: session.handler.native_file
+        handler_id: null
         cookie_secure: auto
         cookie_samesite: lax
-        storage_id: session.storage.native
+        storage_factory_id: session.storage.factory.native

config/packages/framework.yaml

 framework:
     test: true
     session:
-        storage_id: session.storage.mock_file
+        storage_factory_id: session.storage.factory.mock_file

Even though, this looks simple, it took me some time fto figure these out. There are some answers on for example stackoverflow on this. But what I missed, is that not only the setting content changes, but also the settings key!

Update to Symfony 5.4

Finally we can update to the long term Symfony version 5.4. For this we change all the versions "5.3.*" to "5.4.*" in the composer.json and call composer update once more. This works without any problems and just introduces some new deprecations.But they all are from the same problem: in controllers, it is no longer allowed to the get entity manager using $this->getDoctrine()->getManager(). This can be changed easily by passing it to the controllers constructor with EntityManagerInterface $entityManager.

Update the remaining packages

To update all the other packages, I changed one by one in the composer.yaml to a version requirement of " * ", to check if there is a new major version. This was not the case, only minor updates.