Refactor fat controllers into lean controllers by extracting business logic to services, handlers, and dedicated classes
This skill inherits all available tools. When active, it can use any tool Claude has access to.
// 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);
}
<?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;
}
}
<?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,
) {}
}
<?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);
}
}
#[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 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
}
}
#[Route('/contact', methods: ['POST'])]
public function contact(
#[MapRequestPayload] ContactRequest $request
): JsonResponse {
// $request is already validated
$this->contactService->send($request);
return new JsonResponse(['status' => 'sent']);
}
// 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]);
}
// DTO handles validation rules
final readonly class CreateUserRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password;
}
// 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;
}
}
// 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,
};
}
}
// Async via Messenger
class OrderService
{
public function create(CreateOrderRequest $request): Order
{
// ... create order
$this->bus->dispatch(new SendOrderConfirmation($order->getId()));
return $order;
}
}
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);
}
}
new Entity() in controller