SOLID Symfony Apps

Ole Rößner

About me

  • Ole Rößner
  • married, father
  • neusta GmbH (Bremen)
  • Coding, Coaching and Consulting
  • Symfony Enthusiast
  • Clean Code Evangelist
  • Former DJ

@djbasster

@oroessner@mastodon.social

Basster

basster

o.roessner@neusta.de

SOLID

Single Responsibility Principle

Open/Closed Principle

Liskov substitution principle

Interface segregation principle

Dependency inversion principle

SOLID

in

?

There will be code!

Single Responsibility Principle?

A lot of services!

BookingController

BookingRepository

Logger

BookingController

BookingRepository

Logger

<?php declare(strict_types=1);

final class BookingController 
{
    private RepositoryInterface $repository;
    
    private LoggerInterface $logger;

    public function __construct() 
    {
        $this->repository = new BookingRepository();
        $this->logger = new Logger();
    }
}

New?

BookingController

BookingRepository

Logger

<?php declare(strict_types=1);

final class BookingController 
{
    private RepositoryInterface $repository;
    
    private LoggerInterface $logger;

    public function __construct() 
    {
        $this->repository = BookingRepository::getInstance();
        $this->logger = Logger::getInstance();
    }
}

Singleton?

BookingController

BookingRepository

Logger

Dependency Types

Mandatory dependency

Optional dependency

Mandatory dependency

Optional dependency

<?php declare(strict_types=1);

final class BookingController 
{
    private ObjectRepository $repository;
    
    private LoggerInterface $logger;
    
    


    public function __construct(ObjectRepository $repository) 
    {
        $this->repository = $repository;
        $this->setLogger(new NullLogger());
    }
    
    


    public function setLogger(LoggerInterface $logger) : void 
    {
        $this->logger = $logger;
    }
}

DI-Rule-of-Thumb

As specific as possible,

as generic as needed.

Interface segregation principle

symfony/dependency-injection

===

services.yaml

?

symfony/flex

ftw!

$ tree config/packages
config/packages
├── assets.yaml
├── commands.yaml
├── dev
│   ├── (...)
├── doctrine_migrations.yaml
├── doctrine.yaml
├── framework.yaml
├── prod
│   ├── (...)
├── routing.yaml
├── security_checker.yaml
├── security.yaml
├── sensio_framework_extra.yaml
├── swiftmailer.yaml
├── test
│   ├── (...)
├── translation.yaml
├── twig_extensions.yaml
├── twig.yaml
├── validator.yaml
└── webpack_encore.yaml

Controllers

What is a controller?

It's a layer!

Input Land

Business Land

Controllers in Symfony?

MessageHandler

Controllers

Commands

HTTP

CLI

QUEUE

Services 4.2+

  • Standardmäßig private
  • Kein $this->get('service.name') mehr
  • Autowired
  • Autoconfigured
  • Kein BaseController mehr
class BlogController extends AbstractController
{
  // code
}
// \Symfony\Bundle\FrameworkBundle\Controller\AbstractController
abstract class AbstractController implements ServiceSubscriberInterface
{
    /**
     * @var ContainerInterface
     */
    protected $container;

    /**
     * @internal
     * @required
     */
    public function setContainer(ContainerInterface $container)
    {
        // setter code
    }
}
// \Symfony\Bundle\FrameworkBundle\Controller\AbstractController

public static function getSubscribedServices()
{
  return [
    'router' => '?'.RouterInterface::class,
    'request_stack' => '?'.RequestStack::class,
    'http_kernel' => '?'.HttpKernelInterface::class,
    'serializer' => '?'.SerializerInterface::class,
    'session' => '?'.SessionInterface::class,
    'security.authorization_checker' => '?'.AuthorizationCheckerInterface::class,
    'twig' => '?'.Environment::class,
    'doctrine' => '?'.ManagerRegistry::class,
    'form.factory' => '?'.FormFactoryInterface::class,
    'security.token_storage' => '?'.TokenStorageInterface::class,
    'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class,
    'parameter_bag' => '?'.ContainerBagInterface::class,
    'message_bus' => '?'.MessageBusInterface::class,
    'messenger.default_bus' => '?'.MessageBusInterface::class,
  ];
}

Controllers are not Single Responsible!

namespace App\Controller;

class BlogController extends AbstractController
{
    public function index(Request $request, int $page, string $_format, PostRepository $posts, TagRepository $tags): Response
    { /* code */ }

    public function postShow(Post $post): Response
    { /* code */ }

    public function commentNew(Request $request, Post $post, EventDispatcherInterface $eventDispatcher): Response
    { /* code */ }

    public function commentForm(Post $post): Response
    { /* code */ }

    public function search(Request $request, PostRepository $posts): Response
    { /* code */ }
}

Erm...

public function __construct(UriBuilder $uriBuilder, Tag $occasionTarget, 
        TagRepository $tagRepository, PageRenderer $pageRenderer,
        TripRepository $tripRepository, LandingRepository $landingRepository, 
        LandingTeaserService $landingTeaserService, CityRepository $cityRepository, 
        CityDistrictRepository $cityDistrictRepository, 
        FileRepository $fileRepository
    ) {
        parent::__construct();
        $this->uriBuilder = $uriBuilder;
        $this->occasionTarget = $occasionTarget;
        $this->tagRepository = $tagRepository;
        $this->pageRenderer = $pageRenderer;
        $this->tripRepository = $tripRepository;
        $this->landingRepository = $landingRepository;
        $this->landingTeaserService = $landingTeaserService;
        $this->cityRepository = $cityRepository;
        $this->cityDistrictRepository = $cityDistrictRepository;
        $this->fileRepository = $fileRepository;
    }

Single Action Controller in Symfony?

[...]

Actions in Symfony

# config/routes/annotations.yaml
actions:
    resource: ../../src/Action/
    type: annotation
namespace App\Action;

final class Hello
{
    /**
     * @Route("/hello/{who}")
     * @Template("hello.html.twig")
     */
    public function __invoke(string $who): array
    {
        return ['who' => $who];
    }
}
# config/services.yaml
services:
    App\Action\:
        resource: '../src/Action'
        tags: ['controller.service_arguments']

With great power comes great responsibility!

— Uncle Ben

Spot the Issue!

class RoomService extends AbstractController
{
    public function getAllRooms(): Collection
    {
        $rooms = $this->getDoctrine()->getRepository(Room::class)->findAll();
        // do stuff

        // wait! what?!
    }
}
abstract class AbstractController implements ServiceSubscriberInterface

Autoconfigure!

use \Doctrine\Common\Persistence\ObjectRepository;

final class RoomService
{
    private ObjectRepository $repository;

    public function __construct(ObjectRepository $repository)
    {
        $this->repository = $repository;
    }

    public function getAllRooms(): Collection
    {
        $rooms = $this->repository->findAll();
        // do stuff
    }
}

Injecting done right

Refactoring

Quiz Time

class BlogController extends AbstractController {
    public function commentNew(Request $request, Post $post, EventDispatcherInterface $eventDispatcher): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);

        $form = $this->createForm(CommentType::class, $comment);
        $form->handleRequest($request);

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

            $eventDispatcher->dispatch(new CommentCreatedEvent($comment));

            return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
        }

        return $this->render('blog/comment_form_error.html.twig', [
            'post' => $post,
            'form' => $form->createView(),
        ]);
    }
}

@event_dispatcher

@security.token_storage

@form.factory

@doctrine

@router

@twig

Repositories

// Überall in der Doku:

// Entity laden
$entity = $repository->find(1);

// Entity speichern
$entityManager->persist($entity);
public interface IRepository<T>
{
    T find(int id);
    IEnumerable<T> findAll();
    void add(T entity);    
    void edit(T entity);
    void delete(T entity);
}

Separation of Concerns or

Single Responsible Principle?

PHP, please giev Generics!

...or use Psalm

/**
 * @method Comment find(int $id, $lockMode = null, $lockVersion = null)
 * @method Comment[]|Collection findAll()
 */
final class CommentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Comment::class);
    }
    
    public function add(Comment $comment): void
    {
        $this->edit($comment);
        $this->getEntityManager()->flush($comment);
    }

    public function edit(Comment $comment): void
    {
        $this->getEntityManager()->persist($comment);
    }

    public function delete(Comment $comment):void
    {
        $this->getEntityManager()->remove($comment);
    }
}

(Sort of) Repository Pattern

abstract class ValidatingServiceEntityRepository extends ServiceEntityRepository
{
    public function add($entity): void
    {
        $this->edit($entity);
        $this->getEntityManager()->flush($entity);
    }

    public function edit($entity): void
    {
        $this->validateEntity($entity);
        $this->getEntityManager()->persist($entity);
    }

    public function delete($entity): void
    {
        $this->validateEntity($entity);
        $this->getEntityManager()->remove($entity);
    }

    private function validateEntity($entity): void
    {
        if (false === \is_a($entity, $this->getClassName())) {
            throw new \TypeError('Entity is not a '.$this->getClassName());
        }
    }
}

Generische Alternative

Commands & Domain Events

class BlogController extends AbstractController {
    public function commentNew(Request $request, Post $post, EventDispatcherInterface $eventDispatcher): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);

        $form = $this->createForm(CommentType::class, $comment);
        $form->handleRequest($request);

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

            $eventDispatcher->dispatch(new CommentCreatedEvent($comment));

            return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
        }

        return $this->render('blog/comment_form_error.html.twig', [
            'post' => $post,
            'form' => $form->createView(),
        ]);
    }
}
final class BlogController implements MessageBusAware
{
    use MessageBusAwareTrait;
    
    // [...]

    if ($form->isSubmitted() && $form->isValid()) {
        $this->dispatchMessage(new SaveCommentRequest($comment));
    
        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }
}
final class SaveCommentCommandHandler implements MessageHandlerInterface, MessageBusAware
{
    use MessageBusAwareTrait;

    public function __construct(CommentRepository $repository)
    { /* initialize fields */ }
       
    public function __invoke(SaveCommentRequest $request)
    {
        $this->repository->saveAndFlush($request->getComment());
        $this->dispatchMessage(new CommentSavedEvent($comment));
    }
}

Der größte Antrieb des Programmierers, ist die Faulheit.

— Prof. Dr. rer. nat. Helmut Eirund (HS-Bremen)
# config/services.yaml
services:
  _instanceof:
    Basster\SymfonyDiExtras\Messenger\MessageBusAwareInterface:
      calls:
      - [setMessageBus, ['@message_bus']]
    Basster\SymfonyDiExtras\Event\EventDispatcherAwareInterface:
      calls:
      - [setEventDispatcher, ['@event_dispatcher']]
use Basster\SymfonyDiExtras\Messenger\MessageBusAwareInterface;
use Basster\SymfonyDiExtras\Messenger\MessageBusAwareTrait;
use Basster\SymfonyDiExtras\Messenger\NullBus;

final class BlogController implements MessageBusAwareInterface
{
    use MessageBusAwareTrait;
    
    public function __construct() {
        $this->setMessageBus(new NullBus()); // NullObject Pattern!
    }
}
composer req basster/symfony-di-extras
<?php

namespace Psr\Log;

/**
 * This Logger can be used to avoid conditional log calls
 *
 * Logging should always be optional, and if no logger is provided to your
 * library creating a NullLogger instance to have something to throw logs at
 * is a good way to avoid littering your code with `if ($this->logger) { }`
 * blocks.
 */
class NullLogger extends AbstractLogger
{
    /**
     * Logs with an arbitrary level.
     */
    public function log($level, $message, array $context = array())
    {
        // noop
    }
}
final class SomeService implements MessageBusAwareInterface
{   
    public function __construct() 
    {
        $this->setMessageBus(new NullBus()); // <- NullObject by default!
    }
    
    public function setMessageBus(MessageBusInterface $messageBus): void {...}
        
    public function doStuff(): void
    {
    	// instead of...
        if ($this->messageBus) {
          $this->messageBus->dispatch(new FooMessage());
        }
        
        // just do 
        $this->messageBus->dispatch(new FooMessage());
    }
}

Why NullObject Pattern?

Szenario

LinkParser

interface LinkTypeParserInterface
{
    /** @throws UnsupportedLinkTypeException */
    public function getType(Link $link): string;
}

abstract class AbstractLinkTypeParser implements LinkTypeParserInterface
{
    public function getType(Link $link): string { /* ... */ }
}
final class LinkTypeParser implements LinkTypeParserInterface
{
    /** @var LinkTypeParserInterface[]|iterable */
    private iterable $parserCollection;

    public function __construct(iterable $parserCollection)
    {
        $this->parserCollection = $parserCollection;
    }

    public function getType(UserLink $link): string
    {
        $type = 'custom';
        foreach ($this->parserCollection as $parser) {
            try {
                $type = $parser->getType($link);
            } catch (UnsupportedLinkTypeException $ex) {
                continue;
            }
        }

        return $type;
    }
}
Links
├── AbstractLinkTypeParser.php
├── BehanceLinkTypeParser.php
├── CreativeFabricaLinkParser.php
├── CreativeMarketLinkParser.php
├── DribbbleLinkParser.php
├── FacebookLinkTypeParser.php
├── FlickrLinkTypeParser.php
├── FontsComLinkParser.php
├── GoogleFontsLinkParser.php
├── GooglePlusLinkTypeParser.php
├── GraphicRiverLinkParser.php
├── InstagramLinkTypeParser.php
├── LinkTypeParserInterface.php
├── LinkTypeParser.php
├── MyFontsLinkParser.php
├── TwitterLinkTypeParser.php
└── UnsupportedLinkTypeException.php
services:
    _defaults:
      public: false
      autowire: true
      autoconfigure: true

    _instanceof:
      App\Links\AbstractLinkTypeParser:
        tags:
          - { name: app.link.parser }
services:
    App\Links\LinkTypeParser:
      arguments: [!tagged app.link.parser]

MAGIC or SOLID?

  • Single Responsibility Principle 
  • Open/Closed Principle 
  • Liskov substitution principle 
  • Interface segregation principle 
  • Dependency inversion principle 

Refactoring

class BlogController extends AbstractController {
    public function commentNew(Request $request, Post $post): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);
    
        $form = $this->createForm(CommentType::class, $comment); 
        $form->handleRequest($request);
    
        if ($form->isSubmitted() && $form->isValid()) {
            $this->dispatchMessage(new SaveCommentRequest($comment); // NEU!
    
            return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]); 
        }
    
        return $this->render('blog/comment_form_error.html.twig', [
            'post' => $post,
            'form' => $form->createView(),
        ]);
    }
}
class BlogController extends AbstractController {
    public function commentNew(Request $request, Post $post, FormFactoryInterface $formFactory): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);
    
        $form = $formFactory->create(CommentType::class, $comment);
        $form->handleRequest($request);
    
        if ($form->isSubmitted() && $form->isValid()) {
            $this->dispatchMessage(new SaveCommentRequest($comment); // NEU!
    
            return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]); 
        }
    
        return $this->render('blog/comment_form_error.html.twig', [
            'post' => $post,
            'form' => $form->createView(),
        ]);
    }
}
class BlogController extends AbstractController {
    public function commentNew(FormRequest $request, Post $post): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);

        if (!$formRequest->handle(CommentType::class, $comment)) {
            return $this->render('blog/comment_form_error.html.twig', [
                        'post' => $post,
                        'form' => $form->createView(),
                    ]);
        }

        // muss nicht, nur zur besseren Verständnis, da das Form Framework
        // den State von $comment ändert
        $comment = $formRequest->getValidData(); 

        $this->dispatchMessage(new SaveCommentRequest($comment));
    
        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }
}
composer req qafoolabs/no-framework-bundle --no-scripts
# config/packages/no-framework.yaml
services:
    _defaults:
        autowire: true     
        autoconfigure: true

    # di-factory
    QafooLabs\Bundle\NoFrameworkBundle\Request\SymfonyFormRequest:
        class: QafooLabs\Bundle\NoFrameworkBundle\Request\SymfonyFormRequest
        factory: ['@App\Form\SymfonyFormRequestFactory', 'createFormRequest']

    # di-alias
    QafooLabs\MVC\FormRequest: '@QafooLabs\Bundle\NoFrameworkBundle\Request\SymfonyFormRequest'
composer req qafoolabs/no-framework-bundle --no-scripts
class BlogController extends AbstractController {
    public function commentNew(FormRequest $request, Post $post): Response
    {
        $comment = new Comment();
        $comment->setAuthor($this->getUser());
        $post->addComment($comment);

        if (!$formRequest->handle(CommentType::class, $comment)) {
            return $this->render('blog/comment_form_error.html.twig', [
                        'post' => $post,
                        'form' => $form->createView(),
                    ]);
        }

        // muss nicht, nur zur besseren Verständnis
        $comment = $formRequest->getValidData(); 

        $this->dispatchMessage(new SaveCommentRequest($comment));
    
        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }
}
class BlogController extends AbstractController {
    public function commentNew(FormRequest $request, Post $post, TokenContext $context): Response
    {
        $comment = new Comment();
        $comment->setAuthor($context->getCurrentUser());
        $post->addComment($comment);

        if (!$formRequest->handle(CommentType::class, $comment)) {
            return $this->render('blog/comment_form_error.html.twig', [
                        'post' => $post,
                        'form' => $form->createView(),
                    ]);
        }

        // muss nicht, nur zur besseren Verständnis
        $comment = $formRequest->getValidData(); 

        $this->dispatchMessage(new SaveCommentRequest($comment));
    
        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }
}
composer req qafoolabs/no-framework-bundle --no-scripts
class BlogController {
    public function commentNew(FormRequest $request, Post $post, 
            TokenContext $context, Environment $twig, 
            UrlGeneratorInterface $router): Response
    {
        $comment = new Comment();
        $comment->setAuthor($context->getCurrentUser());
        $post->addComment($comment);

        if (!$formRequest->handle(CommentType::class, $comment)) {
            return new Response($twig->render('blog/comment_form_error.html.twig', [
                        'post' => $post,
                        'form' => $form->createView(),
                    ]);
        }

        $this->dispatchMessage(new SaveCommentRequest($formRequest->getValidData());
    
        return new RedirectResponse(
            $router->generate('blog_post', ['slug' => $post->getSlug()])
        );
    }
}
use Basster\LazyResponseBundle\Response\LazyResponseInterface;
use Basster\LazyResponseBundle\Response\RedirectResponse;
use Basster\LazyResponseBundle\Response\TemplateResponse;

class BlogController {
    public function commentNew(FormRequest $request, Post $post, 
                                TokenContext $context): LazyResponseInterface
    {
        $comment = new Comment();
        $comment->setAuthor($context->getCurrentUser());
        $post->addComment($comment);

        if (!$formRequest->handle(CommentType::class, $comment)) {
            return new TemplateResponse('blog/comment_form_error.html.twig', [
                'post' => $post,
                'form' => $form->createView(),
            ]);
        }

        $this->dispatchMessage(new SaveCommentRequest($formRequest->getValidData());
    
        return new RedirectResponse('blog_post', ['slug' => $post->getSlug()]);
    }
}
composer require basster/lazy-response-bundle
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;

final class RedirectResponseHandler extends AbstractLazyResponseHandler
{
    private RouterInterface $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    protected function generateResponse(LazyResponseInterface $controllerResult): Response
    {
        return new SymfonyRedirectResponse(
            $this->router->generate(
                $controllerResult->getRouteName(),
                $controllerResult->getRouteParams()
          ),
            $controllerResult->getStatusCode(),
            $controllerResult->getHeaders()
        );
    }
}
composer require basster/lazy-response-bundle
final class TwigResponseHandler extends AbstractLazyResponseHandler
{
    private Environment $twig;

    public function __construct(Environment $twig)
    {
        $this->twig = $twig;
    }

    protected function generateResponse(LazyResponseInterface $controllerResult): Response
    {
        return new Response(
            $this->twig->render(
                $controllerResult->getTemplate(), 
                $controllerResult->getData()
            ), 
            $controllerResult->getStatus(), 
            $controllerResult->getHeaders()
        );
    }
}
composer require basster/lazy-response-bundle
use Basster\LazyResponseBundle\Response\LazyResponseInterface;
use Basster\LazyResponseBundle\Response\RedirectResponse;
use Basster\LazyResponseBundle\Response\TemplateResponse;

class BlogController {
    public function commentNew(FormRequest $request, Post $post, 
                                TokenContext $context): LazyResponseInterface
    {
        $comment = new Comment();
        $comment->setAuthor($context->getCurrentUser());
        $post->addComment($comment);

        if ($formRequest->handle(CommentType::class, $comment)) {
            $this->dispatchMessage(new SaveCommentRequest($formRequest->getValidData());
    
            return new RedirectResponse('blog_post', ['slug' => $post->getSlug()]);
        }
        
        return new TemplateResponse('blog/comment_form_error.html.twig', [
            'post' => $post,
            'form' => $form->createView(),
        ]);
    }
}

Apropos faul...

Der Template Controller?

final class StaticPagesController {
    private const NO_DATA = [];
    
    /**
     * @Route("/imprint")
     * @Template("static-pages/imprint.html.twig")
     */
    public function imprintAction() : array
    {
        return static::NO_DATA;
    }
   
    /**
     * @Route("/terms-of-service")
     * @Template("static-pages/terms-of-service.html.twig")
     */
    public function termsOfServiceAction() : array
    {
        return static::NO_DATA;
    }
}

Lieber so...

# config/routes/static-pages.yaml
my_imprint:
    path: /imprint
    controller:  Symfony\Bundle\FrameworkBundle\Controller\TemplateController
    defaults:    
        template:     'static-pages/imprint.html.twig'
        maxAge:       86400
        sharedAge:    86400

my_terms-of-service:
    path: /terms-of-service
    controller:  Symfony\Bundle\FrameworkBundle\Controller\TemplateController
    defaults:    
        template:     'static-pages/terms-of-service.html.twig'
        maxAge:       86400
        sharedAge:    86400

Der Redirect Controller?

final class LegacyRedirectController
{
    /**
     * @Route("/old-legacy-page.html")
     */
    public function redirectToNewPage(UrlGeneratorInterface $router): RedirectRoute
    {
        return new RedirectResponse(
            $router->generate('new-shiny-page'),
            Response::HTTP_MOVED_PERMANENTLY
        );
    }
}

Lieber so...

# config/routes/legacy-redirects.yaml
legacy_page:
  path: /old-legacy-page.html
  controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
  defaults:
    route: new-shiny-page
    permanent: true

Happy      Coding!

Bitte gebt mir Feedback!

SOLID Symfony Apps

By neusta Coaching-Team

SOLID Symfony Apps

Symfony is great for getting results fast. For complex applications, some teams often find it difficult to structure applications and keep them maintainable and extensible. Fat controllers, god classes and spaghetti code are the consequences, while Symfony offers so many opportunities to clearly structure even large, complex, monolithic projects by complying with clean code and SOLID principles, dividing them into responsibilities and keeping them maintainable and expandible for a long time. In this talk I would like to show what options Symfony 4 offers here, using a refactoring example.

  • 2,589