/

Symfony 2.7 LTS: deprecations first, upgrade second

2,071 words, about 9 min read

The composer update that moved the ERP from Symfony 2.3.31 to 2.7.3 finished at 21:40 on a Monday. The suite went green forty minutes later. Then I loaded the dispatch dashboard in the dev environment and the web debug toolbar told me we were calling 187 deprecated things on a single page load, which is roughly 186 more than I had budgeted for.

That number is the whole story of this upgrade. Nothing broke in an interesting way. What 2.7 does is tell you, loudly and for the first time, about every deprecated call you have been making since 2013.

Why we moved at all

Symfony 2.3 is an LTS release and it has been a good one. It also stops receiving bug fixes in May 2016 and security fixes in May 2017, according to the schedule in the release process document. Nine months of bug fixes left, on the application that runs purchase orders, invoicing and the production schedule, is not a comfortable place to plan from.

Symfony 2.7 is the next long term support version, released in May 2015, with bug fixes until May 2018 and security fixes until May 2019. It needs PHP 5.3.9 or higher, so our PHP 5.5 boxes were never a question. The numbers that mattered when I wrote the proposal:

VersionReleasedBug fixes untilSecurity fixes until
2.3 (LTS)05/201305/2016 (36 months)05/2017
2.7 (LTS)05/201505/2018 (36 months)05/2019

Three years of bug fixes and four years of security fixes is what the LTS label actually buys. A standard minor version, by the same document, gets eight months of bug fixes and fourteen months of security fixes. That difference is the only reason we are on 2.7 and not 2.6, and it is why I am willing to spend a fortnight on deprecation notices roughly once every two years instead of every eight months.

We did not take 2.7.0. The Form component had enough movement in it that 2.7.1 and 2.7.2 both shipped corrections to the new choice list code: the upgrade notes record that the constructor argument order of the new ChoiceView was wrong in 2.7.0 and fixed in 2.7.1, and that 2.7.2 had to add a LegacyChoiceListAdapter and two methods to the new ChoiceListInterface to fix regressions. Four releases in nine weeks, by the 2.7 changelog: 2.7.0 on 30 May, 2.7.1 on 11 June, 2.7.2 on 13 July, 2.7.3 on 31 July. We waited for 2.7.3, and I would make that call again.

Deprecations first, upgrade second

The single most important line in the 2.7 upgrade notes is in the Global section, before any component: deprecation notices are now triggered with @trigger_error('... is deprecated ...', E_USER_DEPRECATED) whenever you use deprecated functionality. They are silenced by default, so production logs stay quiet, and they show up in the web debug toolbar and the profiler where you cannot pretend not to see them.

The notices are specific in a way that makes the work mechanical. This is what our purchase order controller produced on the first run, word for word out of Form.php:

Passing a SymfonyComponentHttpFoundationRequest object to the
SymfonyComponentFormForm::bind and SymfonyComponentFormForm::bind methods
is deprecated since Symfony 2.3 and will be removed in 3.0. Use the
SymfonyComponentFormForm::handleRequest method instead. If you want to test
whether the form was submitted separately, you can use the
SymfonyComponentFormForm::isSubmitted method.

Deprecated since 2.3. We had been running on 2.3 for two years without ever being told. That is the part of this release I would put on a poster: the version you upgrade to is the version that starts complaining, which means the complaints you get are about debts accumulated over the previous three minor versions, not about 2.7.

2.7 also ships the PHPUnit bridge, which collects those notices during a test run and prints them grouped by message and by test, with counts. By default it fails the suite on any non-legacy notice. You can set SYMFONY_DEPRECATIONS_HELPER to a number to allow that many, to a regular expression to stop and dump the stack trace for one particular message, or to weak to ignore them entirely. The docs note that the bridge can be installed in any Symfony application, even 2.3, which sounds like a way to do the work before you upgrade. It is not, quite: on 2.3 the framework does not trigger the notices, so the report comes back nearly empty. You get the list after you move.

bind() to handleRequest

The form change was the largest single edit, in 23 controllers. Our code was the shape the Symfony 2.0 documentation taught, copied forward by everyone who touched it, including me:

<?php
// Before: AppBundleControllerPurchaseOrderController
public function editAction(Request $request, $id)
{
    $order = $this->getDoctrine()
        ->getRepository('AppBundle:PurchaseOrder')
        ->find($id);

    $form = $this->createForm(new PurchaseOrderType(), $order);

    if ('POST' === $request->getMethod()) {
        $form->bind($request);

        if ($form->isValid()) {
            $this->getDoctrine()->getManager()->flush();

            return $this->redirect($this->generateUrl('po_show', array('id' => $id)));
        }
    }

    return $this->render('AppBundle:PurchaseOrder:edit.html.twig', array(
        'form' => $form->createView(),
    ));
}

// After
public function editAction(Request $request, $id)
{
    $order = $this->getDoctrine()
        ->getRepository('AppBundle:PurchaseOrder')
        ->find($id);

    $form = $this->createForm(new PurchaseOrderType(), $order);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->getDoctrine()->getManager()->flush();

        return $this->redirect($this->generateUrl('po_show', array('id' => $id)));
    }

    return $this->render('AppBundle:PurchaseOrder:edit.html.twig', array(
        'form' => $form->createView(),
    ));
}

handleRequest() is not new. The Form changelog puts RequestHandlerInterface and FormInterface::handleRequest() in 2.3.0, in the same entry that renamed bind() and isBound() to submit() and isSubmitted(). The real win is not the line count. It is that handleRequest() knows about PATCH, and that the explicit method check we were doing by hand was wrong for one endpoint the mobile client hits with PUT. That bug has been there since November 2013 and nobody reported it, because the mobile client retries with POST when the first attempt does nothing.

The deprecation that touched 46 form types

Every form type we own overrode setDefaultOptions(). All 46 of them, plus 3 type extensions, produced this, from the reflection check ResolvedFormType now runs on every type it resolves:

AppBundleFormTypePurchaseOrderType: The FormTypeInterface::setDefaultOptions()
method is deprecated since Symfony 2.7 and will be removed in 3.0.
Use configureOptions() instead. This method will be added to the
FormTypeInterface with Symfony 3.0.

The fix is a rename plus an import change, because the signature changes with it: setDefaultOptions(OptionsResolverInterface $resolver) becomes configureOptions(OptionsResolver $resolver), and OptionsResolverInterface itself was deprecated back in 2.6 on the grounds that a resolver is not meant to be shared between classes.

-use SymfonyComponentOptionsResolverOptionsResolverInterface;
+use SymfonyComponentOptionsResolverOptionsResolver;

-    public function setDefaultOptions(OptionsResolverInterface $resolver)
+    public function configureOptions(OptionsResolver $resolver)
     {
         $resolver->setDefaults(array(
             'data_class' => 'AppBundleEntityPurchaseOrder',
         ));
     }

Two greps and a careful review of the four types that were doing something clever with the resolver. An hour, most of it reading.

The rest of the form work was not mechanical. The choice_list option is deprecated in favour of choice_loader, and our warehouse picker built an ObjectChoiceList by hand, so that one got rewritten against DefaultChoiceListFactory. The entity type’s property option is now choice_label, which is a straight rename in 11 places. empty_value became placeholder back in 2.6 and says so at runtime. Translation of Doctrine choice labels is off by default now, so two selects that were being translated silently stopped, and we set choice_translation_domain to true on both.

The one I chose not to do yet is flipping the choices arrays. In 2.7 the option still reads label-to-value in the old order and choices_as_values still defaults to false, with the upgrade notes saying the default becomes true in 3.0. Flipping it means touching every choice array and, where JavaScript depends on the rendered value attribute, adding a choice_value callback. That is a separate branch and a separate week.

security.context exists in name only

The security work came from 2.6, not 2.7, but you meet it when you land on 2.7. The class announces itself now:

The SymfonyComponentSecurityCoreSecurityContext class is deprecated since
Symfony 2.6 and will be removed in 3.0. Use
SymfonyComponentSecurityCoreAuthenticationTokenStorageTokenStorage or
SymfonyComponentSecurityCoreAuthorizationAuthorizationChecker instead.

UPGRADE-2.6 splits the old interface cleanly: isGranted() belongs to AuthorizationCheckerInterface, getToken() and setToken() to TokenStorageInterface, and in the container security.context becomes security.authorization_checker and security.token_storage. 2.7 then reduces injection of the old service to a bare minimum and changes a list of core listeners to take the token storage instead, so anything extending them has to follow: ContextListener, ExceptionListener, SwitchUserListener, AccessListener, RememberMeListener and the authentication listeners.

We had nine services injecting security.context, one of them a Twig extension that renders the approval trail on every purchase order. What made it cheap is a 2.6 addition I had missed entirely: the base Controller class gained isGranted() and denyAccessUnlessGranted() helpers, along with redirectToRoute(), addFlash() and isCsrfTokenValid(). Half of our custom controller base class turned out to be reimplementing those, badly, and got deleted.

Asset, CSS escaping, and the component nobody installs

2.7 adds the Asset component, whose changelog entry for 2.7.0 reads, in full, “added the component”. It is now a hard dependency of FrameworkBundle: its composer.json requires symfony/asset at ~2.7. Since we install symfony/symfony the whole thing arrived without us noticing. If you install components individually, that is a line you add by hand.

The Twig side moved with it. The bridge’s new AssetExtension provides asset() and asset_version(), and the old extra arguments to asset() now trigger notices with exactly this wording: “Generating absolute URLs with the Twig asset() function was deprecated in 2.7 and will be removed in 3.0. Please use absolute_url() instead.” Our invoice and dispatch notification emails were full of that third argument, since emails need absolute URLs:

-<img src="{{ asset('bundles/app/img/logo.png', null, true) }}" alt="" />
+<img src="{{ absolute_url(asset('bundles/app/img/logo.png')) }}" alt="" />

absolute_url() and relative_path() come from the new HttpFoundation Twig extension in the same release, and assets_version() is deprecated in favour of asset_version(). Eleven templates, no judgement required.

The Twig change that did require judgement is escaping. TwigDefaultEscapingStrategy is deprecated in favour of Twig_FileExtensionEscapingStrategy, which means a .css.twig template is now escaped with the CSS strategy rather than HTML. We have one, a theme file that interpolates a colour for the print stylesheet, and #1f4e79 came out mangled until we added |raw. The upgrade notes call this out with a CSS example, which is the only reason I found it in ten minutes rather than a day.

On expression language: neither FrameworkBundle nor SecurityBundle requires symfony/expression-language, both list it under require-dev only, so expression based access control or route conditions mean requiring it yourself unless you pull in symfony/symfony. Worth knowing before you use the other router change in this release, which is that route conditions now substitute container parameters written as %parameter%. The upgrade notes warn that this breaks conditions using % as modulo: foo%bar%2 compiled as arithmetic in 2.6 and now looks like a parameter named bar. We have no conditions in our routing at all, which for once was the right amount of cleverness.

Where this leaves us: production went to 2.7.3 on a Tuesday evening, eleven days after the branch was cut, and the only incident was the mangled print stylesheet. The dashboard is down from 187 deprecation notices to 6, all of them from a third-party bundle we do not control, and CI runs with SYMFONY_DEPRECATIONS_HELPER=6, a number I dislike having in a config file because it will quietly stop being right. 2.8 and 3.0 are both scheduled for November, and 3.0 removes everything 2.7 has been warning me about. The choices arrays are the next branch.

Sources

  • Symfony 2.7 release page: release month, end of bug fixes (May 2018), end of security fixes (May 2019) and the PHP 5.3.9 minimum.
  • The release process document: LTS means three years of bug fixes and four years of security fixes, standard versions get eight and fourteen months, plus the schedule table with 2.3 and 2.7 dates.
  • UPGRADE-2.7.md: the deprecation notice mechanism, the form and Twig escaping changes, the security listener list, the router parameter substitution trap, and the 2.7.1 and 2.7.2 choice list corrections.
  • UPGRADE-2.6.md: the security.context split into security.token_storage and security.authorization_checker, and the OptionsResolverInterface deprecation.
  • Form component changelog: handleRequest() arriving in 2.3.0, and the 2.7.0 list of deprecated choice list classes and options.
  • AssetExtension source: the exact deprecation messages for absolute URLs, forced versions and assets_version().
  • FrameworkBundle composer.json: symfony/asset as a hard requirement, symfony/expression-language only under require-dev.
  • PHPUnit bridge documentation: the deprecation report format and the SYMFONY_DEPRECATIONS_HELPER modes.