From symfony-skills
Enforces layered architecture in Symfony: controllers handle HTTP, services contain business logic, repositories handle data access, DTOs map between layers. Use when generating controllers, services, repositories, or DTOs.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:layered-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
```
Controller (#[Route]) ← HTTP only. No business logic. No entities in responses.
↓ DTOs
Service (application) ← All business logic. Orchestrates repositories. Owns the transaction.
↓ Entities / domain objects
Repository (Doctrine) ← Data access only. No business logic. Returns entities or read models.
↓ DQL / DBAL
Database
Dependencies point downward only. A repository never calls a service; a service never sees an Request.
Response.try/catch for domain errors — let an exception listener turn them into HTTP responses.// ✅ GOOD
#[Route('/orders', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateOrderRequest $request): JsonResponse
{
$order = $this->orderService->createOrder($request);
return $this->json(OrderResponse::fromEntity($order), Response::HTTP_CREATED);
}
// ❌ BAD — business logic + direct repository + entity returned
#[Route('/orders', methods: ['POST'])]
public function create(Request $request, OrderRepository $orders): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (empty($data['items'])) {
throw new \RuntimeException('No items'); // domain rule in controller
}
$order = new Order($data);
$orders->save($order); // controller talks to repository
return $this->json($order); // entity leaks out
}
EntityManagerInterface::flush() / wrapInTransaction()).ContainerInterface / service locator pulls.OrderService, not OrderAndPaymentService).Request / Response.// ✅ GOOD
final class OrderService
{
public function __construct(
private readonly OrderRepository $orders,
private readonly InventoryService $inventory,
private readonly EntityManagerInterface $em,
) {}
public function createOrder(CreateOrderRequest $request): Order
{
$this->inventory->reserve($request->items);
$order = Order::create($request->customerEmail);
foreach ($request->items as $item) {
$order->addItem($item->productId, $item->quantity);
}
$this->em->persist($order);
$this->em->flush();
return $order;
}
}
// ❌ BAD — service container pull, HTTP type in service
final class OrderService
{
public function __construct(private ContainerInterface $container) {}
public function createOrder(Request $request): Response { /* ... */ }
}
ServiceEntityRepository<Entity>.// ✅ GOOD
/** @extends ServiceEntityRepository<Order> */
final class OrderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Order::class);
}
/** @return Order[] */
public function findActiveByCustomer(string $email): array
{
return $this->createQueryBuilder('o')
->andWhere('o.customerEmail = :email')
->andWhere('o.status = :status')
->setParameter('email', $email)
->setParameter('status', OrderStatus::Active)
->getQuery()
->getResult();
}
}
dto-and-validation).OrderResponse::fromEntity($order).readonly classes / promoted readonly properties — DTOs are immutable.// ✅ GOOD
final readonly class OrderResponse
{
/** @param LineItemResponse[] $items */
public function __construct(
public string $id,
public string $status,
public array $items,
) {}
public static function fromEntity(Order $order): self
{
return new self(
$order->getId()->toRfc4122(),
$order->getStatus()->value,
array_map(LineItemResponse::fromEntity(...), $order->getItems()->toArray()),
);
}
}
OrderResponse::fromEntity($order).Order::create(...)) or a mapper service.array_map(OrderResponse::fromEntity(...), $orders) — first-class callable syntax.config/services.yaml; rely on autowiring + autoconfiguration.#[Autowire] bound parameters or a typed config object — not scattered %env()% reads inside services.config/services.yaml, never inside a service class.Psr\Log\LoggerInterface — never error_log() / dump() in committed code.#[MapRequestPayload] validates the DTO automatically; custom rules via constraint validators.#[AsEventListener(KernelEvents::EXCEPTION)] listener — never try/catch in controllers for domain errors.ContainerInterface / uses $this->get() — use constructor injection of the concrete dependency.flush() and transaction logic in the controller — move it to the service.OrderAndInventoryService) — split by aggregate.json_decode($request->getContent()) — use #[MapRequestPayload] with a DTO.try/catches domain exceptions in the controller — let the exception listener map them.npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsImplements Clean Architecture, Hexagonal (Ports & Adapters), and Domain-Driven Design patterns in PHP 8.3+ with Symfony 7.x. For enterprise app architecture, legacy refactoring, DDD, and testable backends.
Structures Symfony apps with ports & adapters / clean architecture, keeping the domain framework-free. Use for complex, long-lived domains where isolating business logic from the framework pays off.
Provides Symfony framework reference with architecture patterns, DDD integration, clean architecture checklists, common violations, and antipatterns for auditing PHP projects.