6

I am adherent of Action Class approach using instead of Controller. The explanation is very simple: very often Controller includes many actions, when following the Dependency Injection principle we must pass all required dependencies to a constructor and this makes a situation when the Controller has a huge number of dependencies, but in the certain moment of time (e.g. request) we use only some dependencies. It's hard to maintain and test that spaghetti code.

To clarify, I've already used to work with that approach in Zend Framework 2, but there it's named Middleware. I've found something similar in API-Platform, where they also use Action class instead of Controller, but the problem is that I don't know how to cook it.

UPD: How can I obtain the next Action Class and replace standard Controller and which configuration I should add in regular Symfony project?

<?php
declare(strict_types=1);

namespace App\Action\Product;

use App\Entity\Product;
use Doctrine\ORM\EntityManager;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class SoftDeleteAction
{
    /**
     * @var EntityManager
     */
    private $entityManager;

    /**
     * @param EntityManager $entityManager
     */
    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * @Route(
     *     name="app_product_delete",
     *     path="products/{id}/delete"
     * )
     *
     * @Method("DELETE")
     *
     * @param Product $product
     *
     * @return Response
     */
    public function __invoke(Request $request, $id): Response
    {
        $product = $this->entityManager->find(Product::class, $id);
        $product->delete();
        $this->entityManager->flush();

        return new Response('', 204);
    }
}
Serhii Popov
  • 3,326
  • 2
  • 25
  • 36
  • This sort of question is probably better suited for the Reddit Symfony forum. Having said that, an Action is simply a Controller with a single action method. Nothing really to implement. You might consider updating your question with more details before it gets closed. – Cerad Aug 23 '20 at 13:00
  • I took a look at your linked SoftDeleteAction class. I should point out that link only questions tend to be as frowned upon as link only answers. In any event, the code should work out of the box except for injecting the Product entity. The easiest fix is to inject the id and then use the entity manager to retrieve it. – Cerad Aug 23 '20 at 13:44
  • @Cerad I've edited my question and add code sample. Thank you for your clarification. – Serhii Popov Aug 23 '20 at 14:03
  • I suspect your routing is not getting picked up? Out of the box, the route annotation processor only look in the Controller directory (config/routes/annotation.yaml). The easiest fix is to just define your route in config/routes.yaml. Since you are using the __invoke method then you just need the action class for _controller. The method=DELETE might cause some problems as well. For now just use POST and then check the docs for how to fake using DELETE. – Cerad Aug 23 '20 at 14:07
  • @Cerad Can you explain more detailed how-to configure routes? Now I have the next configuration in my `app/config/routing.yml` (see screen https://i.imgur.com/aRptcwd.png) app_product: resource: "@AppProduct/Controller/" type: annotation prefix: / – Serhii Popov Aug 23 '20 at 14:11

2 Answers2

6

The approach I was trying to implement is named as ADR pattern (Action-Domain-Responder) and Symfony has already supported this started from 3.3 version. You can refer to it as Invokable Controllers.

From official docs:

Controllers can also define a single action using the __invoke() method, which is a common practice when following the ADR pattern (Action-Domain-Responder):

// src/Controller/Hello.php
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/hello/{name}", name="hello")
 */
class Hello
{
    public function __invoke($name = 'World')
    {
        return new Response(sprintf('Hello %s!', $name));
    }
}

Serhii Popov
  • 3,326
  • 2
  • 25
  • 36
4

The question is a bit vague for stackoverflow though it's also a bit interesting. So here are some configure details.

Start with an out of the box S4 skeleton project:

symfony new --version=lts s4api
cd s4api
bin/console --version # 4.4.11
composer require orm-pack

Add the SoftDeleteAction

namespace App\Action\Product;
class SoftDeleteAction
{
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }
    public function __invoke(Request $request, int $id) : Response
    {
        return new Response('Product ' . $id);
    }
}

And define the route:

# config/routes.yaml
app_product_delete:
    path: /products/{id}/delete
    controller: App\Action\Product\SoftDeleteAction

At this point the wiring is almost complete. If you go to the url you get:

The controller for URI "/products/42/delete" is not callable:

The reason is that services are private by default. Normally you would extend from AbstractController which takes care of making the service public but in this case the quickest approach is to just tag the action as a controller:

# config/services.yaml
    App\Action\Product\SoftDeleteAction:
        tags: ['controller.service_arguments']

At this point you should have a working wired up action.

There of course many variations and a few more details. You will want to restrict the route to POST or fake DELETE.

You might also consider adding an empty ControllerServiceArgumentsInterface and then using the services instanceof functionality to apply the controller tag so you no longer need to manually define your controller services.

But this should be enough to get you started.

Cerad
  • 48,157
  • 8
  • 90
  • 92