Claude Code Plugins

Community-maintained marketplace

Feedback

symfony:controller-cleanup

@MakFly/superpowers-symfony
15
0

Refactor fat controllers into lean controllers by extracting business logic to services, handlers, and dedicated classes

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name symfony:controller-cleanup
description Refactor fat controllers into lean controllers by extracting business logic to services, handlers, and dedicated classes

Controller Cleanup

The Problem: Fat Controllers

// BAD: Fat controller with too much logic
#[Route('/orders', methods: ['POST'])]
public function create(Request $request): Response
{
    $data = json_decode($request->getContent(), true);

    // Validation logic in controller
    if (empty($data['items'])) {
        return new JsonResponse(['error' => 'Items required'], 400);
    }

    // Business logic in controller
    $order = new Order();
    $order->setCustomer($this->getUser());
    $order->setStatus('pending');

    $total = 0;
    foreach ($data['items'] as $itemData) {
        $product = $this->em->find(Product::class, $itemData['productId']);
        if (!$product) {
            return new JsonResponse(['error' => 'Product not found'], 400);
        }

        if ($product->getStock() < $itemData['quantity']) {
            return new JsonResponse(['error' => 'Insufficient stock'], 400);
        }

        $item = new OrderItem();
        $item->setProduct($product);
        $item->setQuantity($itemData['quantity']);
        $item->setPrice($product->getPrice());
        $order->addItem($item);

        $total += $product->getPrice() * $itemData['quantity'];
        $product->setStock($product->getStock() - $itemData['quantity']);
    }

    $order->setTotal($total);

    // Coupon logic
    if (!empty($data['coupon'])) {
        $coupon = $this->em->getRepository(Coupon::class)
            ->findOneBy(['code' => $data['coupon']]);
        if ($coupon && $coupon->isValid()) {
            $discount = $total * ($coupon->getDiscount() / 100);
            $order->setDiscount($discount);
            $order->setTotal($total - $discount);
        }
    }

    $this->em->persist($order);
    $this->em->flush();

    // Send email
    $email = (new Email())
        ->to($this->getUser()->getEmail())
        ->subject('Order Confirmation')
        ->text('Your order has been placed.');
    $this->mailer->send($email);

    return new JsonResponse(['id' => $order->getId()], 201);
}

The Solution: Lean Controller

Step 1: Extract to Service

<?php
// src/Service/OrderService.php

namespace App\Service;

use App\Dto\CreateOrderRequest;
use App\Entity\Order;
use App\Entity\User;

class OrderService
{
    public function __construct(
        private ProductService $products,
        private CouponService $coupons,
        private EntityManagerInterface $em,
        private OrderNotificationService $notifications,
    ) {}

    public function createOrder(User $user, CreateOrderRequest $request): Order
    {
        // Validate and reserve products
        $items = $this->products->reserveItems($request->items);

        // Create order
        $order = Order::create($user, $items);

        // Apply coupon if provided
        if ($request->couponCode) {
            $discount = $this->coupons->apply($request->couponCode, $order);
            $order->applyDiscount($discount);
        }

        $this->em->persist($order);
        $this->em->flush();

        // Async notification
        $this->notifications->orderCreated($order);

        return $order;
    }
}

Step 2: Use DTOs for Input

<?php
// src/Dto/CreateOrderRequest.php

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateOrderRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Count(min: 1)]
        #[Assert\Valid]
        public array $items,

        public ?string $couponCode = null,
    ) {}
}

Step 3: Lean Controller

<?php
// src/Controller/Api/OrderController.php

namespace App\Controller\Api;

use App\Dto\CreateOrderRequest;
use App\Service\OrderService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/orders')]
class OrderController extends AbstractController
{
    public function __construct(
        private OrderService $orderService,
    ) {}

    #[Route('', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateOrderRequest $request
    ): JsonResponse {
        $order = $this->orderService->createOrder(
            $this->getUser(),
            $request
        );

        return new JsonResponse(['id' => $order->getId()], 201);
    }
}

Controller Patterns

Maximum 5-10 Lines Per Action

#[Route('/posts/{id}', methods: ['PUT'])]
public function update(
    Post $post,
    #[MapRequestPayload] UpdatePostRequest $request
): JsonResponse {
    $this->denyAccessUnlessGranted('EDIT', $post);

    $post = $this->postService->update($post, $request);

    return new JsonResponse(PostOutput::fromEntity($post));
}

Use Attributes for Common Tasks

use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/users')]
#[IsGranted('ROLE_ADMIN')]
class AdminUserController extends AbstractController
{
    #[Route('', methods: ['GET'])]
    public function list(): Response
    {
        // Already authorized by class attribute
    }
}

MapRequestPayload for Input

#[Route('/contact', methods: ['POST'])]
public function contact(
    #[MapRequestPayload] ContactRequest $request
): JsonResponse {
    // $request is already validated
    $this->contactService->send($request);

    return new JsonResponse(['status' => 'sent']);
}

ParamConverter for Entities

// Symfony automatically converts {id} to Post entity
#[Route('/posts/{id}', methods: ['GET'])]
public function show(Post $post): Response
{
    // 404 handled automatically if not found
    return $this->render('post/show.html.twig', ['post' => $post]);
}

Extract Responsibilities

Validation → DTO + Validator

// DTO handles validation rules
final readonly class CreateUserRequest
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password;
}

Business Logic → Service

// Service handles business rules
class UserService
{
    public function register(CreateUserRequest $request): User
    {
        $this->ensureEmailUnique($request->email);
        $user = User::register($request->email, $request->password);
        $this->em->persist($user);
        $this->em->flush();
        return $user;
    }
}

Authorization → Voter

// Voter handles access control
class PostVoter extends Voter
{
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        return match ($attribute) {
            'EDIT' => $subject->getAuthor() === $token->getUser(),
            default => false,
        };
    }
}

Notifications → Events/Messages

// Async via Messenger
class OrderService
{
    public function create(CreateOrderRequest $request): Order
    {
        // ... create order
        $this->bus->dispatch(new SendOrderConfirmation($order->getId()));
        return $order;
    }
}

Testing Lean Controllers

class OrderControllerTest extends WebTestCase
{
    public function testCreateOrder(): void
    {
        $user = UserFactory::createOne();
        ProductFactory::createMany(3);

        $this->client->loginUser($user->object());
        $this->client->request('POST', '/api/orders', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'items' => [
                ['productId' => 1, 'quantity' => 2],
            ],
        ]));

        $this->assertResponseStatusCodeSame(201);
    }
}

Checklist

  • Controller actions ≤ 10 lines
  • No new Entity() in controller
  • No direct EntityManager usage
  • Use DTOs for input
  • Use services for business logic
  • Use voters for authorization
  • Use events/messages for side effects