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
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.
# 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,817