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!