Serhii Popov
Serhii Popov

Reputation: 3804

Use Action class instead of Controller in Symfony

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);
    }
}

Upvotes: 6

Views: 4170

Answers (2)

Serhii Popov
Serhii Popov

Reputation: 3804

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));
    }
}

Upvotes: 7

Cerad
Cerad

Reputation: 48865

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.

Upvotes: 4

Related Questions