Slide 1

Slide 1 text

Avoid Costly Framework Upgrades LONGHORN PHP | OCT 2021 @afilina

Slide 2

Slide 2 text

"I want to replace Zend Framework 1, but it looks like a complete rewrite."

Slide 3

Slide 3 text

"I want to keep up with my framework, but it's a lot of work with each new version."

Slide 4

Slide 4 text

"I am stuck on this old framework, which also makes me stuck on PHP 5."

Slide 5

Slide 5 text

Anna Filina • Coding since 1997 (VB4) • PHP since 2003 • Legacy archaeology • Test automation • Public speaking • Mentorship • YouTube videos

Slide 6

Slide 6 text

Upgrade or Change? It depends

Slide 7

Slide 7 text

• Was it abandoned? • Is the new version compatible? • Current version might not last forever.

Slide 8

Slide 8 text

2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 Major incompatibility Symfony 1 Symfony 2 Symfony 3 Symfony 4 Symfony 5 Four years worth of apps that are hard to upgrade.

Slide 9

Slide 9 text

2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 Zend Framework 1 Zend Framework 2 Zend Framework 3 Renamed to Laminas Major incompatibility The different between 1 and 2 is so big, it's like a different framework.

Slide 10

Slide 10 text

2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 Laravel 1, Laravel 2 Laravel 3 Laravel 4 Laravel 5 Laravel 6 Laravel 7, Laravel 8 Many applications written here A little more time on recent versions means less legacy.

Slide 11

Slide 11 text

2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 Laravel 1, Laravel 2 Laravel 3 Laravel 4 Laravel 5 Laravel 6 Laravel 7, Laravel 8 No support after Sept 2022 You have 10 months to upgrade and deploy.

Slide 12

Slide 12 text

It takes more than 10 months to upgrade? You're already late.

Slide 13

Slide 13 text

• Spend a year upgrading your app. • New framework version comes out. • You need another upgrade. It's a common situation with large applications.

Slide 14

Slide 14 text

Should you upgrade less often? No, it will hurt more. The more versions you skip, the harder it will be to upgrade.

Slide 15

Slide 15 text

Should frameworks be supported longer? No, unless you pay a maintainer. You can't expect maintainers to maintain dozens of versions.

Slide 16

Slide 16 text

Should frameworks avoid breaking changes? No, innovation is important. We want frameworks to get better, and that means change.

Slide 17

Slide 17 text

What can I do to make upgrades easier? Decouple your code from the framework.

Slide 18

Slide 18 text

How Am I Coupled?

Slide 19

Slide 19 text

• Controllers Controllers can access sessions, validation, form builders, logging, service manager, etc.

Slide 20

Slide 20 text

• Controllers • Views & helpers Partial views, layout, helpers to generate HTML.

Slide 21

Slide 21 text

• Controllers • Views & helpers • Routing Not always complex, but each framework configures it differently.

Slide 22

Slide 22 text

• Controllers • Views & helpers • Routing • Databases ORMs map objects to tables, build complex queries and provide various shortcuts.

Slide 23

Slide 23 text

• Controllers • Views & helpers • Routing • Databases • Utility classes Utility classes may not exist elsewhere, or become discontinues.

Slide 24

Slide 24 text

• Controllers • Views & helpers • Routing • Databases • Utility classes • Etc. Each framework does things in a unique way that that may be completely incompatible with another framework.

Slide 25

Slide 25 text

Controllers

Slide 26

Slide 26 text

// Symfony 5 class LuckyController extends AbstractController { /** * @Route("/lucky/number") */ public function number(): Response { $number = random_int(0, 100); return $this->render('lucky/number.html.twig', [ 'number' => $number, ]); } } We use methods inherited from the parent.

Slide 27

Slide 27 text

AbstractController LuckyController template files configuration service container etc. AbstractController requires bootstrapping the framework. It can be mitigated with framework-specific testing tools, but then you're coupled to those.

Slide 28

Slide 28 text

// Symfony 5 class LuckyController extends AbstractController { /** * @Route("/lucky/number") */ public function number(): Response { $number = random_int(0, 100); return $this->render('lucky/number.html.twig', [ 'number' => $number, ]); } } We shouldn't use a partial mock here. It's a bad practice.

Slide 29

Slide 29 text

return $this->container ->get('twig') ->render($view, $parameters); Under the hood, it's just a tiny shortcut, but comes at a cost of maintainability

Slide 30

Slide 30 text

Dependency Inversion Almost every framework today already provides that mechanism.

Slide 31

Slide 31 text

LuckyController Twig Instead of extending AbstractController, we'll inject the services that we need

Slide 32

Slide 32 text

TwigTemplating LaminasViewTemplating LuckyController Templating We can improve further by depending on an interface, decoupling us from the specific templating engine.

Slide 33

Slide 33 text

interface Templating { public function render(string $templateName, array $data): string; } This is the method we'll call from our controller.

Slide 34

Slide 34 text

final class TwigTemplating implements Templating { public function __construct( private Environment $twig ) {} public function render(string $template, array $data): string { return $this->twig->render($template . '.html.twig', $data); } } Inject Twig, the Implement method by using Twig to render. This is now an adapter for Twig.

Slide 35

Slide 35 text

class LuckyController { public function __construct( private Templating $templating ) {} public function number(Request $request): Response { $content = $this->templating->render( 'lucky/number', ['number' => random_int(0, 100)] ); return new Response($content); } } Drop the parent. Add the interface to the constructor. The service container will decide which concrete class to inject.

Slide 36

Slide 36 text

LuckyController Templating TwigTemplating LaminasViewTemplating At no point does our controller depend on Symfony or Twig directly.

Slide 37

Slide 37 text

LuckyController Templating This makes it easy to mock the dependencies.

Slide 38

Slide 38 text

$templatingMock = $this->createMock(Templating::class); $controller = new LuckyController($templatingMock); $response = $controller->number(new Request(/**/)); We're not relying on any specialized testing tools that come with the framework. When switching frameworks, we don't need to update the tests.

Slide 39

Slide 39 text

Other Frameworks

Slide 40

Slide 40 text

// Zend Framework 1 class LuckyController extends Zend_Controller_Action { public function numberAction() { //... $this->view->number = $number; $this->render('lucky/number'); if ($number < 10) { $this->render('lucky/below-ten'); } } } ZF1 assigns variables to parent, then renders directly to the browser and continues execution.

Slide 41

Slide 41 text

$viewVars['number'] = $number; $content .= $this->templating ->render('lucky/number', $viewVars); if ($number < 10) { $content .= $this->templating ->render('lucky/below-ten', $viewVars); } echo $content; Add variables to an array, then pass it to render. Concatenate and output all at once. This uses the same templating interface.

Slide 42

Slide 42 text

MVC frameworks return from controllers differently.

Slide 43

Slide 43 text

void Zend Framework 1 Response Symfony 5 Response Laravel ViewModel Laminas MVC This means that controllers are not 100% portable, but decoupling already saves us a lot of effort in an upgrade.

Slide 44

Slide 44 text

Invest once. Save on each upgrade. Upgrade more often. Gain in testability. Even if you don't end up changing the framework, what you'll gain in testability will make you save time today.

Slide 45

Slide 45 text

More Decoupling

Slide 46

Slide 46 text

// Zend Framework 1 view products as $product): ?> = $this->escapeHtml($product->name) ?> = $this->customerViewHelper($product->amount) ?> Those helpers may or may not exist in the target framework, or they may exist but accept different arguments, or exhibit a different behavior.

Slide 47

Slide 47 text

EscapeHtml escapeHtml Zf1EscapeHtml LaminasEscapeHtml Same approach as with templating. Depend on interfaces, so you can swap implementations.

Slide 48

Slide 48 text

EscapeHtml escapeHtml Zf1EscapeHtml LaminasEscapeHtml MyEscapeHtml Trivial helpers can be simply copy-pasted and become part of your code.

Slide 49

Slide 49 text

Routing

Slide 50

Slide 50 text

// Symfony 5 class LuckyController extends AbstractController { /** * @Route("/lucky/number") */ public function number(): Response { $number = random_int(0, 100); return $this->render('lucky/number.html.twig', [ 'number' => $number, ]); } } If you move this code to a different framework, you'd have to redesign your routing.

Slide 51

Slide 51 text

// Zend Framework 1 class LuckyController extends Zend_Controller_Action { public function numberAction() { //... } } ZF1 has no concept of routing at all. It's all based on convention: class and method name.

Slide 52

Slide 52 text

# Symfony 1 # apps/frontend/config/routing.yml default: url: /:module/:action/* Symfony 1 has a similar approach using convention, but can also define custom routes in YAML.

Slide 53

Slide 53 text

• Annotations • Attributes • XML config • PHP config • YAML config Symfony 5 has a lot more options, which also means that every project has a different challenge.

Slide 54

Slide 54 text

# Symfony 1 blog_list: path: /blog param: { module: blog, action: list } # Symfony 5 blog_list: path: /blog controller: App\Controller\BlogController::list Even if both can use YAML, it has differences. I typically write a script that converts the routing information from and to whatever I need.

Slide 55

Slide 55 text

We need a PSR for routing. This would be a nice way to avoid having to convert the routing each time a new format comes out.

Slide 56

Slide 56 text

ORMs I used to love them, but they really don't age well.

Slide 57

Slide 57 text

// Eloquent 8 class Flight extends Model { protected $table = 'my_flights'; protected $dateFormat = 'U'; } Gives access to methods, but that also means it's hard to mock, or need to be mocked with framework-specific tools.

Slide 58

Slide 58 text

// Eloquent 8 class Flight extends Model { protected $table = 'my_flights'; protected $dateFormat = 'U'; } Mapping is done differently in every ORM, and there are features that might simply not exist in the new ORM.

Slide 59

Slide 59 text

// Doctrine 1 class App_Model_Flight extends Doctrine_Record { public function setTableDefinition() { $this->setTableName('my_flights'); } } Doctrine 1 also extends, but it's a different parent, with different methods and a new way to define mapping.

Slide 60

Slide 60 text

// Doctrine 2 /** * @ORM\Entity * @ORM\Table(name="my_flights") */ class Flight { } In Doctrine 2, we don't extend, so we can't call save on it. We need the EntityManager for that. Mapping can be done many different ways, including annotations.

Slide 61

Slide 61 text

Do you really need an ORM? I personally don't use ORMs anymore. The maintenance effort far outweighs all advantages, in my opinion.

Slide 62

Slide 62 text

FlightRepository Flight (needs configuration) Instead of automatically mapping my objects to tables based on configuration, I can just instantiate the Flight myself, using plain old PHP.

Slide 63

Slide 63 text

final class DatabaseFlightRepository implements FlightRepository { public function findByRoute(Airport $origin, Airport $destination) { $rows = $this->db->prepare(/**/)->execute(/**/); $flights = array_map(function(array $row) { return Flight::fromRow($row); }, $rows); return $flights; } } I can then prepare and execute a plain query. Complex query builders can make it hard to upgrade. I then convert from array to object manually.

Slide 64

Slide 64 text

FlightRepository Flight (needs configuration) Because I map the record myself, I don't need any ORM- specific configuration. These classes become portable.

Slide 65

Slide 65 text

• Extending a FW class. • Statically calling or instantiating a FW class. • FW-specific configuration files. • Automatic behavior based on conventions. • FW-specific annotations. • Interacting with a complex API. Most common ways in which you can be tightly coupled with a framework

Slide 66

Slide 66 text

Framework Maintainers

Slide 67

Slide 67 text

Can frameworks make it easier? Yes, but it's a tall order. It's a lot of work, and maintainers might have their own idea about what a framework should and should not be responsible for.

Slide 68

Slide 68 text

Can frameworks show better examples? Yes, but it's a tall order. Many frameworks compete on simplicity to attract users.

Slide 69

Slide 69 text

Decoupled code makes for more complex examples.

Slide 70

Slide 70 text

"Look how simple it is." vs "Look at this harder example. Now you can move off our framework more easily."

Slide 71

Slide 71 text

Is There A Good Example?

Slide 72

Slide 72 text

class LuckyNumberHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request) : ResponseInterface { $content = $this->templating->render('lucky/number', ['number' => $number] ); return new Response($content); } } Mezzio is already in line with how I do things myself already. One action per controller, reducing dependencies per class.

Slide 73

Slide 73 text

class LuckyNumberHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request) : ResponseInterface { $content = $this->templating->render('lucky/number', ['number' => $number] ); return new Response($content); } } All these interfaces are part of PSR-7 and PSR-15. All frameworks could start offering this alternative.

Slide 74

Slide 74 text

Request Handler Response It's the not the only thing I like in Mezzio, but also there are things I don't like. It still feels like the right direction for frameworks.

Slide 75

Slide 75 text

Decouple now. No need to wait for a framework upgrade. Even if you don't plan to upgrade your framework any time soon, you'll have more testable code that is easier to maintain.

Slide 76

Slide 76 text

@afilina Questions?