Replacing abandoned Symfony bundles: SensioFrameworkExtra, Guard, Swiftmailer, FOSRest, FOSUser
What each abandoned bundle did, what replaces it in Symfony 6.4 and 7.4, and the code change involved, with before and after examples.
Published 2026-09-12. Written for teams doing the upgrade themselves; if you would rather have it done, see fixed-price upgrades. Also available in Deutsch.
Why abandoned bundles decide the upgrade
A package that is abandoned has no release that allows the next Symfony major. It does not matter how small it is: Composer will refuse the update until it is gone. On a 5.4 codebase the abandoned list is usually the same five names, and each one maps to a feature Symfony absorbed into core. This guide shows, per bundle, what it did, what replaced it, and the before/after code. The compatibility pages list the abandoned status of the 500 most used bundles.
SensioFrameworkExtraBundle
Abandoned since Symfony 6.2 shipped its features in core. Does not install on Symfony 7. Its annotations, and their core replacements:
| Annotation | Core replacement | Since |
|---|---|---|
@Route | #[Route] (symfony/routing) | 5.2 |
@ParamConverter | #[MapEntity] (doctrine-bundle) and #[MapDateTime] | 6.2 |
@IsGranted, @Security | #[IsGranted] with attribute or new Expression() | 6.2 |
@Cache | #[Cache] (http-kernel) | 6.2 |
@Template | Explicit $this->render(); no replacement by design | – |
// Before
/**
* @Route("/customers/{id}/orders/{orderId}", name="customer_order")
* @ParamConverter("order", options={"id": "orderId"})
* @Security("is_granted('VIEW', order) and order.customer == customer")
* @Cache(maxage=300, public=true)
* @Template("order/show.html.twig")
*/
public function show(Customer $customer, Order $order): array
{
return ['customer' => $customer, 'order' => $order];
}
// After
#[Route('/customers/{id}/orders/{orderId}', name: 'customer_order')]
#[IsGranted(new Expression("is_granted('VIEW', subject['order']) and subject['order'].customer == subject['customer']"), subject: ['order', 'customer'])]
#[Cache(maxage: 300, public: true)]
public function show(#[MapEntity] Customer $customer, #[MapEntity(id: 'orderId')] Order $order): Response
{
return $this->render('order/show.html.twig', ['customer' => $customer, 'order' => $order]);
}
@ParamConverter with a custom converter class (a class implementing ParamConverterInterface) becomes a ValueResolverInterface service tagged controller.argument_value_resolver; the interface is one method, resolve(Request $request, ArgumentMetadata $argument): iterable. The bundle's 6.2 release forwards its annotations to the core attributes, which lets you install it on 6.x while you migrate and remove it at the end. Rector's SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES converts @Route, @IsGranted and @Cache; @ParamConverter and @Template are manual.
symfony/security-guard (Guard authenticators)
Removed in Symfony 6.0. Replacement: the authenticator system in symfony/security-http, available as an opt-in on 5.4 (enable_authenticator_manager: true). The mental model changes from "seven methods that return credentials, users, and booleans" to "one authenticate() that returns a Passport made of badges".
// Before: form login Guard (typical make:auth output from 4.x/5.x)
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
public function supports(Request $request) { return 'app_login' === $request->attributes->get('_route') && $request->isMethod('POST'); }
public function getCredentials(Request $request) { /* read email, password, csrf */ }
public function getUser($credentials, UserProviderInterface $userProvider) { /* find user */ }
public function checkCredentials($credentials, UserInterface $user) { return $this->hasher->isPasswordValid($user, $credentials['password']); }
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) { /* redirect */ }
protected function getLoginUrl() { return $this->urlGenerator->generate('app_login'); }
}
// After
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public const LOGIN_ROUTE = 'app_login';
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function authenticate(Request $request): Passport
{
$email = $request->getPayload()->getString('email');
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
return new Passport(
new UserBadge($email),
new PasswordCredentials($request->getPayload()->getString('password')),
[new CsrfTokenBadge('authenticate', $request->getPayload()->getString('_csrf_token')), new RememberMeBadge()],
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->getTargetPath($request->getSession(), $firewallName) ?? $this->urlGenerator->generate('app_dashboard'));
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
}
}
# security.yaml
security:
password_hashers: # was 'encoders'
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: auto
firewalls:
main:
lazy: true # replaces 'anonymous: true'
provider: app_user_provider
custom_authenticator: App\Security\LoginFormAuthenticator
logout: { path: app_logout }
remember_me: { secret: '%kernel.secret%' }
Plain form logins and HTTP basic no longer need a class: form_login:, json_login:, http_basic:, access_token: (6.2+) and login_link: are built in. Most custom Guards were API-key or JWT checks; those become AbstractAuthenticator with a SelfValidatingPassport, or access_token: with an AccessTokenHandlerInterface that returns a UserBadge.
Swiftmailer and symfony/swiftmailer-bundle
Abandoned; the bundle has no Symfony 6 release. Replacement: symfony/mailer (stable since 4.4) with symfony/mime. Transports are configured by DSN, HTML mails are Twig templates, async sending is Messenger.
// Before
$message = (new \Swift_Message('Your invoice'))
->setFrom(['billing@example.com' => 'Billing'])
->setTo($customer->getEmail())
->setBody($this->twig->render('emails/invoice.html.twig', ['invoice' => $invoice]), 'text/html')
->attach(\Swift_Attachment::fromPath($pdfPath));
$this->swiftMailer->send($message);
// After
$email = (new TemplatedEmail())
->from(new Address('billing@example.com', 'Billing'))
->to($customer->getEmail())
->subject('Your invoice')
->htmlTemplate('emails/invoice.html.twig')
->context(['invoice' => $invoice])
->addPart(new DataPart(new File($pdfPath), 'invoice.pdf', 'application/pdf'));
$this->mailer->send($email); // Symfony\Component\Mailer\MailerInterface
# .env
MAILER_DSN=smtp://user:pass@smtp.example.com:587
# config/packages/messenger.yaml, for the old spool behaviour
framework:
messenger:
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Twig templates written for Swiftmailer work unchanged. Inline CSS via twig/cssinliner-extra and Markdown via twig/markdown-extra replace the usual Swiftmailer plugins. Swift_Events plugins become MessageEvent listeners.
FOSRestBundle
Status: maintained at a minimum; its maintainers have recommended API Platform since 2021 and 3.x installs on Symfony 7 but adds nothing core does not do. Two exits, depending on what you used it for.
You used it for view handling and format negotiation on classic controllers. Core has that now:
// Before
/** @Rest\Get("/orders/{id}") @Rest\View(serializerGroups={"order:read"}) */
public function getOrder(Order $order) { return $order; }
/** @Rest\Post("/orders") @ParamConverter("input", converter="fos_rest.request_body") */
public function postOrder(CreateOrder $input, ConstraintViolationListInterface $violations) { /* check $violations */ }
// After
#[Route('/orders/{id}', methods: ['GET'])]
public function getOrder(#[MapEntity] Order $order): JsonResponse
{
return $this->json($order, context: ['groups' => ['order:read']]);
}
#[Route('/orders', methods: ['POST'])]
public function postOrder(#[MapRequestPayload] CreateOrder $input): JsonResponse
{
// validated already: a ConstraintViolation results in a 422 before the action runs (Symfony 6.3+)
}
You used it as your API framework (routing by resource, ParamFetcher, body listeners, exception formatting). Then the replacement is API Platform, one resource set at a time, keeping URLs stable with explicit uriTemplate values. FOSRest and API Platform can coexist in the same application during the migration; give each its own route prefix and firewall.
FOSUserBundle
Status: maintained but legacy; it couples the user entity to a security stack Symfony replaced twice. Replacement: MakerBundle-generated code plus two small bundles:
bin/console make:user # entity + provider
bin/console make:security:form-login # authenticator, login controller, template (6.2+)
bin/console make:registration-form # with symfonycasts/verify-email-bundle
bin/console make:reset-password # with symfonycasts/reset-password-bundle
Keep the fos_user table and map the generated User entity to it (same columns: username, email, password, roles, enabled). Passwords hashed by FOSUser with bcrypt or Argon2 keep working with password_hashers: { ...: auto }. Groups become plain roles or a Group entity of your own; the bundle's group feature is the one thing without a drop-in.
doctrine/annotations and the rest
- doctrine/annotations: abandoned. Nothing in Symfony 7, Doctrine ORM 3, API Platform 3+ or PHPUnit 12 reads annotations. Convert with Rector and remove the package; see the ORM guide.
- doctrine/cache, doctrine/doctrine-cache-bundle: replaced by
symfony/cache; DoctrineBundle configures ORM caches with cache pools. - twig/extensions:
twig/extra-bundlewithtwig/intl-extra,twig/string-extra.localizeddatebecomesformat_datetime. - symfony/templating: Twig directly.
- sensiolabs/security-checker:
composer audit. - symfony/security-acl: not abandoned, but unmaintained in practice; voters on your own entities replace it for almost every use.
Doing this in the right order
All of the above is possible on Symfony 5.4 with PHP 8.1, and that is where it should happen: one bundle per pull request, each one deployable, so that the eventual 6.4 bump touches configuration and composer.json only. The 5.4 to 6.4 guide puts these steps in sequence with the rest of the upgrade.
Which abandoned packages are in your composer.lock?
The scan flags every abandoned package with its recommended replacement, and shows which of them block the Symfony branch you are aiming for.
More guides
- Upgrading Symfony 5.4 to 6.4: the complete guide
- Upgrading Symfony 6.4 to 7.4: deprecations, return types and the jump
- API Platform 2.6 to 3: migrating resources, data providers and persisters
- API Platform 3 to 4: the state-based flow, RFC 7807 errors and Hydra changes
- Upgrading Doctrine ORM 2 to 3 (and DBAL 3 to 4) in a Symfony application