Share
Embed
Skinny controller, skinny model, skinny latte
An experiment on how an application logic could be truely decoupled from framework controllers, presented on the Inviqa Developers Day in London.
Warning: This is an experiment, never used on a real project and probably requires few more iterations to make it usable.
#inviqadevday #Symfony2 #phpspec
Jakub Zalas
June 19, 2014
Transcript
81 slides
-
Skinny controller, skinny model, skinny latte Jakub Zalas Inviqa Dev
Day 19th June 2014 -
Controller View Model Request Where is Doctrine on this picture?
* it's a detail -
namespace SensioLabs\ShopBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Component\HttpFoundation\Request; class ProductController
extends Controller { /** * @Route("/", name="search") */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } } -
class ProductController extends Controller { /** * @Route("/", name="search") */
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } } -
class ProductController extends Controller { /** * @Route("/", name="search") */
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort = $request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } } -
MORE CHANGE Let's redirect to a brand page if its
name matches keywords! -
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =
$request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } -
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =
$request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $brand = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Brand') ->findOneByName($keywords); if ($brand) { return $this->redirect($this->generateUrl( 'brand', ['name' => $brand->getName()] )); } $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } -
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =
$request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $brand = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Brand') ->findOneByName($keywords); if ($brand) { return $this->redirect($this->generateUrl( 'brand', ['name' => $brand->getName()] )); } $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } -
class ProductController extends Controller { /** * @Route("/", name="search") */
public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return ['products' => $products]; } } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return ['products' => $products]; } } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->get('shop.repository.product') ->search($keywords); return ['products' => $products]; } } -
<?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="shop.repository.product" class="SensioLabs\ProductCatalog\ProductRepository"
factory-service="doctrine" factory-method="getRepository"> <argument>Shop:Product</argument> </service> </services> </container> -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $this->dispatch(new SearchEvent($keywords)); $products = $this->get('shop.repository.product') ->search($keywords); $this->dispatch(new SearchResultsEvent( $keywords, $products )); return ['products' => $products]; } } -
class ProductController extends Controller { // ... public function dispatch(Event
$event) { $this->get('event_dispatcher')->dispatch($event); } } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $event = new SearchEvent($keywords); $this->dispatch($event); if ($event->hasResponse()) { return $event->getResponse(); } $products = $this->get('shop.repository.product') ->search($keywords); // ... return ['products' => $products]; } } -
class SearchQuery { private $keywords; private $sort; private $brand; public
function __construct($keywords, $sort = 'ASC', $brand) { $this->keywords = $keywords; $this->sort = $sort; $this->brand = $brand; } public function hasKeywords() { return null !== $this->keywords; } public function getKeywords() { return $this->keywords; } // ... } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $this->dispatch(new SearchEvent($keywords)); $products = $this->get('shop.repository.product') ->search($keywords); $this->dispatch(new SearchResultsEvent( $keywords, $products )); return ['products' => $products]; } } -
class ProductController extends Controller { /** * @Route("/", name="search") *
@ParamConverter( * name="searchQuery", * converter="search_query" * ) * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->dispatch(new SearchEvent($serachQuery)); $products = $this->get('shop.repository.product') ->search($searchQuery); $this->dispatch(new SearchResultsEvent( $searchQuery, $products )); return ['products' => $products]; } } -
namespace SensioLabs\ShopBundle\Request\ParamConverter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface; use SensioLabs\ProductCatalog\SearchQuery; use Symfony\Component\HttpFoundation\Request;
class SearchQueryConverter implements ParamConverterInterface { public function apply( Request $request, ParamConverter $configuration ) { } public function supports(ParamConverter $configuration) { } } -
class SearchQueryConverter implements ParamConverterInterface { public function apply( Request $request,
ParamConverter $configuration ) { $searchQuery = new SearchQuery( $request->query->get('keywords'), $request->query->get('sort'), $request->query->get('brand') ); $request->attributes->set( $configuration->getName(), $searchQuery ); } public function supports(ParamConverter $configuration) { return $configuration->getClass() === SearchQuery::class; } } -
<?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="sensiolabs_shop.param_converter.search_query" class="SensioLabs\ShopBundle\Request\ParamConverter\SearchQueryConverter">
<tag name="request.param_converter" converter="search_query" /> </service> </services> </container> -
/** * @Route(service="shop.controller.product") */ class ProductController { private $dispatcher; private
$productRepo; public function __construct( EventDispatcherInterface $dispathcer, ProductRepository $productRepo ) { $this->dispatcher = $dispatcher; $this->productRepo = $productRepo; } } -
class ProductController { /** * @Route("/", name="search") * @ParamConverter( *
name="searchQuery", * converter="shop.converter.search_query" * ) * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->dispatcher->dispatch( new SearchEvent($serachQuery) ); $products = $this->productRepo->search($searchQuery); $this->dispatcher->dispatch(new SearchResultsEvent( $searchQuery, $products )); return ['products' => $products]; } } -
<?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="shop.controller.product" class="SensioLabs\ShopBundle\Controller\ProductController">
<argument type="service" id="event_dispatcher" /> <argument type="service" id="shop.repository.product" /> </service> </services> </container> -
namespace spec\SensioLabs\Shop\UseCase; use PhpSpec\ObjectBehavior; use SensioLabs\ProductCatalog\Brand; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\Product;
use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCaseSpec extends ObjectBehavior { function let( ProductRepository $productRepository, BrandRepository $brandRepository, SearchListener $searchListener, SearchQuery $searchQuery ) { $this->beConstructedWith( $productRepository, $brandRepository); $this->append($searchListener); $searchQuery->hasKeywords()->willReturn(true); } } Live coding -
function it_notifies_onNoKeywords_if_there_is_no_keywords ( SearchListener $searchListener, SearchQuery $searchQuery ) { $searchQuery->hasKeywords()->willReturn(false);
$searchListener->onNoKeywords()->shouldBeCalled(); $this->search($searchQuery); } Live coding -
function it_notifies_onSearchResults_if_products_are_found( SearchListener $searchListener, SearchQuery $searchQuery, ProductRepository $productRepository, Product $product
) { $productRepository->search($searchQuery) ->willReturn([$product]); $searchListener->onSearchResults([$product]) ->shouldBeCalled(); $this->search($searchQuery); } Live coding -
function it_notifies_onBrandFound_if_brand_is_found( SearchListener $searchListener, SearchQuery $searchQuery, BrandRepository $brandRepository, Brand $brand
) { $brandRepository->search($searchQuery)->willReturn($brand); $searchListener->onBrandFound($brand)->shouldBeCalled(); $this->search($searchQuery); } Live coding -
function it_notifies_onNoSearchResults_if_no_product_was_found( SearchListener $searchListener, SearchQuery $searchQuery, ProductRepository $productRepository ) {
$productRepository->search($searchQuery)->willReturn([]); $searchListener->onNoSearchResults($searchQuery) ->shouldBeCalled(); $this->search($searchQuery); } Live coding -
namespace SensioLabs\Shop\UseCase; interface SearchListener { public function onNoKeywords(); public function
onSearchResults(array $products); public function onBrandFound(Brand $brand); public function onNoSearchResults(SearchQuery $searchQuery); } The listener interface defines use case events Live coding -
namespace SensioLabs\Shop\UseCase; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCase
{ /** * @var ProductRepository */ private $productRepository; /** * @var BrandRepository */ private $brandRepository; /** * @var SearchListener */ private $searchListener; // ... } Live coding -
namespace SensioLabs\Shop\UseCase; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCase
{ // ... public function __construct( ProductRepository $productRepository, BrandRepository $brandRepository ) { $this->productRepository = $productRepository; $this->brandRepository = $brandRepository; } public function append(SearchListener $searchListener) { $this->searchListener = $searchListener; } // ... } Live coding -
public function search(SearchQuery $searchQuery) { if (!$searchQuery->hasKeywords()) { $this->getSearchListener()->onNoKeywords(); return;
} if ($brand = $this->brandRepository->search($searchQuery)) { $this->getSearchListener()->onBrandFound($brand); return; } $products = $this->productRepository->search($searchQuery); if ($products) { $this->getSearchListener()->onSearchResults($products); return; } $this->getSearchListener()->onNoSearchResults($searchQuery); } Live coding -
private function getSearchListener() { if (null === $this->searchListener) { throw
new \LogicException( 'No search listener was attached' ); } return $this->searchListener; } Live coding -
namespace SensioLabs\ShopBundle\Controller; // use omitted /** * @Route(service="sensiolabs_shop.controller.product") */ class
ProductController implements SearchListener { /** * @var SearchUseCase */ private $searchUseCase; /** * @var HttpFacade */ private $httpFacade; private $response; // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @param
SearchUseCase $searchUseCase * @param HttpFacade $httpFacade */ public function __construct( SearchUseCase $searchUseCase, HttpFacade $httpFacade ) { $this->searchUseCase = $searchUseCase; $this->searchUseCase->attach($this); $this->httpFacade = $httpFacade; } // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @Route("/search",
name="search") * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->searchUseCase->search($searchQuery); return $this->response; } // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @param
Brand $brand * * @return null */ public function onBrandFound(Brand $brand) { $this->response = new RedirectResponse( $this->httpFacade->generate( 'brand', ['slug' => $brand->getSlug()] ) ); } // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @param
Product[] $products * * @return null */ public function onSearchResults(array $products) { $this->httpFacade->setTemplateVars( ['products' => $products] ); } // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @return
null */ public function onNoKeywords() { $this->httpFacade->setTemplate( 'SensioLabsShopBundle:Product:nokeywords.html.twig' ); } // ... } Live coding -
class ProductController implements SearchListener { // ... /** * @param
SearchQuery $searchQuery * * @return null */ public function onNoSearchResults(SearchQuery $searchQuery) { $this->httpFacade->setTemplate( 'SensioLabsShopBundle:Product:noresults.html.twig' ); } // ... } Live coding -
namespace SensioLabs\ShopBundle\Controller; // ... use statements omitted class HttpFacade {
/** * @var RequestStack */ private $requestStack; /** * @var Router */ private $router; public function __construct( RequestStack $requestStack, Router $router ) { $this->requestStack = $requestStack; $this->router = $router; } } Live coding -
class HttpFacade { // ... /** * @param string $template
*/ public function setTemplate($template) { $this->getRequestAttributes()->set( '_template', $template ); } } Live coding -
class HttpFacade { // ... /** * @param array $vars
*/ public function setTemplateVars(array $vars) { $this->getRequestAttributes()->set( '_template_vars', array_keys($vars) ); foreach ($vars as $name => $value) { $this->getRequestAttributes()->set($name, $value); } } } Live coding -
class HttpFacade { // ... /** * @param string $name
* @param array $parameters * @param bool $referenceType * * @return string */ public function generateUrl( $name, array $parameters = [], $referenceType = Router::ABSOLUTE_PATH ) { return $this->router->generate( $name, $parameters, $referenceType ); } } Live coding -
class HttpFacade { // ... /** * @return ParameterBag */
private function getRequestAttributes() { return $this->getRequest()->attributes; } /** * @return null|Request */ private function getRequest() { return $this->requestStack->getCurrentRequest(); } } Live coding -
class SearchCommand extends ContainerAwareCommand implements SearchListener { // ... protected
function execute( InputInterface $input, OutputInterface $output ) { $searchUseCase = $this->getContainer() ->get('sensiolabs_shop.use_case.search'); $searchUseCase->attach($this); $keywords = $input->getArgument('keywords'); $searchQuery = new SearchQuery($keywords); $products = $searchUseCase->search($searchQuery); } // ... } -
class SearchCommand extends ContainerAwareCommand implements SearchListener { // ... public
function onNoKeywords() { } public function onSearchResults(array $products) { } public function onBrandFound(Brand $brand) { } public function onNoSearchResults(SearchQuery $searchQuery) { } }