CtrlK
BlogDocsLog inGet started
Tessl Logo

giuseppe-trisciuoglio/developer-kit

Comprehensive developer toolkit providing reusable skills for Java/Spring Boot, TypeScript/NestJS/React/Next.js, Python, PHP, AWS CloudFormation, AI/RAG, DevOps, and more.

90

Quality

90%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Risky

Do not use without reviewing

This version of the tile failed moderation
Moderation pipeline encountered an internal error
Overview
Quality
Evals
Security
Files

symfony-implementation.mdplugins/developer-kit-php/skills/clean-architecture/references/

Symfony Implementation Guide

Project Setup

Directory Structure

project/
├── config/
│   ├── packages/
│   │   ├── doctrine.yaml
│   │   └── messenger.yaml
│   └── services.yaml
├── src/
│   ├── Domain/
│   ├── Application/
│   ├── Adapter/
│   ├── Infrastructure/
│   └── Kernel.php
├── tests/
│   ├── Unit/
│   ├── Integration/
│   └── Infrastructure/
└── composer.json

Composer Dependencies

{
    "require": {
        "php": ">=8.3",
        "symfony/framework-bundle": "^7.0",
        "symfony/dependency-injection": "^7.0",
        "symfony/messenger": "^7.0",
        "symfony/serializer": "^7.0",
        "symfony/property-access": "^7.0",
        "symfony/uid": "^7.0",
        "doctrine/orm": "^3.0",
        "doctrine/dbal": "^4.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "symfony/test-pack": "^1.0"
    }
}

Dependency Injection Configuration

Autowiring Repository Interface

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude:
            - '../src/Domain/Entity/'
            - '../src/Kernel.php'

    # Bind interface to implementation
    App\Domain\Repository\UserRepositoryInterface:
        class: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository

    App\Domain\Repository\OrderRepositoryInterface:
        class: App\Adapter\Persistence\Doctrine\Repository\DoctrineOrderRepository

Multiple Implementations with Named Services

# config/services.yaml
services:
    # Primary: Doctrine implementation
    App\Domain\Repository\UserRepositoryInterface:
        alias: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository

    # Alternative: In-memory for testing
    App\Infrastructure\Repository\InMemoryUserRepository:
        class: App\Infrastructure\Repository\InMemoryUserRepository

    # Use named autowiring
    App\Application\Handler\CreateUserHandler:
        arguments:
            $userRepository: '@App\Domain\Repository\UserRepositoryInterface'

#[Autowire] Attribute (PHP 8)

<?php
// src/Infrastructure/Service/SendgridEmailService.php

namespace App\Infrastructure\Service;

use App\Application\Service\EmailServiceInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

readonly class SendgridEmailService implements EmailServiceInterface
{
    public function __construct(
        #[Autowire('%env(SENDGRID_API_KEY)%')]
        private string $apiKey,
        #[Autowire('%env(SENDGRID_FROM_EMAIL)%')]
        private string $fromEmail
    ) {
    }

    public function send(string $to, string $subject, string $body): void
    {
        // Sendgrid implementation
    }
}

Doctrine ORM Mapping

XML Mapping (Recommended)

<!-- config/doctrine/User.orm.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="https://doctrine-project.org/schemas/orm/doctrine-mapping
                                      https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

    <entity name="App\Domain\Entity\User" table="users">
        <id name="id" type="string" length="36">
            <generator strategy="NONE"/>
        </id>

        <embedded name="email" class="App\Domain\ValueObject\Email"/>

        <field name="name" type="string" length="100"/>

        <field name="createdAt" type="datetime_immutable"/>

        <field name="isActive" type="boolean"/>
    </entity>
</doctrine-mapping>

Value Object Embeddable

<!-- config/doctrine/Email.orm.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping">

    <embeddable name="App\Domain\ValueObject\Email">
        <field name="value" type="string" column="email" length="255"/>
    </embeddable>

</doctrine-mapping>

Attribute Mapping (Alternative)

<?php
// src/Domain/Entity/User.php

namespace App\Domain\Entity;

use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\Column(type: 'string', length: 36)]
    private string $id;

    #[ORM\Embedded(class: Email::class)]
    private Email $email;

    #[ORM\Column(type: 'string', length: 100)]
    private string $name;

    #[ORM\Column(type: 'datetime_immutable')]
    private DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'boolean')]
    private bool $isActive = true;

    // ... methods
}

Controllers with Attributes

REST API Controller

<?php
// src/Adapter/Http/Controller/Api/UserController.php

namespace App\Adapter\Http\Controller\Api;

use App\Adapter\Http\Request\CreateUserRequest;
use App\Adapter\Http\Request\UpdateUserRequest;
use App\Application\Command\CreateUserCommand;
use App\Application\Command\UpdateUserCommand;
use App\Application\Handler\CreateUserHandler;
use App\Application\Handler\UpdateUserHandler;
use App\Application\Query\GetUserQuery;
use App\Domain\ValueObject\UserId;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[AsController]
#[Route('/api/users')]
class UserController
{
    public function __construct(
        private CreateUserHandler $createHandler,
        private UpdateUserHandler $updateHandler,
        private GetUserQuery $getUserQuery
    ) {
    }

    #[Route('', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateUserRequest $request
    ): JsonResponse {
        $id = Uuid::v4()->toRfc4122();

        $command = new CreateUserCommand(
            id: $id,
            email: $request->email,
            name: $request->name
        );

        ($this->createHandler)($command);

        return new JsonResponse(
            ['id' => $id, 'message' => 'User created'],
            Response::HTTP_CREATED
        );
    }

    #[Route('/{id}', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $user = $this->getUserQuery->execute(new UserId($id));

        if ($user === null) {
            return new JsonResponse(
                ['error' => 'User not found'],
                Response::HTTP_NOT_FOUND
            );
        }

        return new JsonResponse($user);
    }

    #[Route('/{id}', methods: ['PUT'])]
    public function update(
        string $id,
        #[MapRequestPayload] UpdateUserRequest $request
    ): JsonResponse {
        $command = new UpdateUserCommand(
            id: $id,
            name: $request->name
        );

        ($this->updateHandler)($command);

        return new JsonResponse(['message' => 'User updated']);
    }
}

Request Validation with Attributes

<?php
// src/Adapter/Http/Request/CreateUserRequest.php

namespace App\Adapter\Http\Request;

use Symfony\Component\Validator\Constraints as Assert;

class CreateUserRequest
{
    #[Assert\NotBlank(message: 'Email is required')]
    #[Assert\Email(message: 'Invalid email format')]
    public string $email;

    #[Assert\NotBlank(message: 'Name is required')]
    #[Assert\Length(
        min: 2,
        max: 100,
        minMessage: 'Name must be at least {{ limit }} characters',
        maxMessage: 'Name cannot exceed {{ limit }} characters'
    )]
    public string $name;
}

Symfony Messenger for Commands

Command Bus Configuration

# config/packages/messenger.yaml
framework:
    messenger:
        default_bus: command.bus

        buses:
            command.bus:
                middleware:
                    - doctrine_transaction_middleware

        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'

        routing:
            App\Application\Command\SendNotificationCommand: async

Message Handler

<?php
// src/Application/Handler/CreateUserHandler.php

namespace App\Application\Handler;

use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler(bus: 'command.bus')]
readonly class CreateUserHandler
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {
    }

    public function __invoke(CreateUserCommand $command): void
    {
        $user = User::create(
            new UserId($command->id),
            new Email($command->email),
            $command->name
        );

        $this->userRepository->save($user);
    }
}

Dispatching Commands

<?php
// src/Adapter/Http/Controller/UserController.php

use Symfony\Component\Messenger\MessageBusInterface;

class UserController
{
    public function __construct(
        private MessageBusInterface $commandBus
    ) {
    }

    #[Route('/api/users', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        $command = new CreateUserCommand(
            id: Uuid::v4()->toRfc4122(),
            email: $data['email'],
            name: $data['name']
        );

        $this->commandBus->dispatch($command);

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

Domain Events with Symfony EventDispatcher

Domain Event Listener

<?php
// src/Infrastructure/Event/DomainEventDispatcher.php

namespace App\Infrastructure\Event;

use App\Domain\Event\UserCreatedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: UserCreatedEvent::class, method: 'onUserCreated')]
readonly class UserCreatedListener
{
    public function __construct(
        private LoggerInterface $logger,
        private EmailServiceInterface $emailService
    ) {
    }

    public function onUserCreated(UserCreatedEvent $event): void
    {
        $this->logger->info('User created', ['user_id' => $event->userId]);

        // Send welcome email
        $this->emailService->sendWelcomeEmail($event->userId);
    }
}

Publishing Domain Events

<?php
// src/Adapter/Persistence/Doctrine/Repository/DoctrineUserRepository.php

namespace App\Adapter\Persistence\Doctrine\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

readonly class DoctrineUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private EventDispatcherInterface $eventDispatcher
    ) {
    }

    public function save(User $user): void
    {
        $this->entityManager->persist($user);
        $this->entityManager->flush();

        // Dispatch domain events
        foreach ($user->domainEvents() as $event) {
            $this->eventDispatcher->dispatch($event);
        }
        $user->clearDomainEvents();
    }
}

Testing with Symfony

Integration Test

<?php
// tests/Integration/Controller/UserControllerTest.php

namespace App\Tests\Integration\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function testCanCreateUser(): void
    {
        $client = static::createClient();

        $client->request(
            'POST',
            '/api/users',
            [],
            [],
            ['CONTENT_TYPE' => 'application/json'],
            json_encode([
                'email' => 'test@example.com',
                'name' => 'John Doe'
            ])
        );

        $this->assertResponseStatusCodeSame(201);

        $response = json_decode($client->getResponse()->getContent(), true);
        $this->assertArrayHasKey('id', $response);
    }

    public function testReturns404ForNonExistentUser(): void
    {
        $client = static::createClient();

        $client->request('GET', '/api/users/non-existent-id');

        $this->assertResponseStatusCodeSame(404);
    }
}

Repository Integration Test

<?php
// tests/Integration/Repository/UserRepositoryTest.php

namespace App\Tests\Integration\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class UserRepositoryTest extends KernelTestCase
{
    private UserRepositoryInterface $repository;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->repository = self::getContainer()->get(UserRepositoryInterface::class);
    }

    public function testCanSaveAndRetrieveUser(): void
    {
        $user = User::create(
            UserId::generate(),
            new Email('test@example.com'),
            'John Doe'
        );

        $this->repository->save($user);

        $retrieved = $this->repository->findById($user->id());

        $this->assertNotNull($retrieved);
        $this->assertTrue($user->email()->equals($retrieved->email()));
    }
}

Error Handling

Domain Exception Handler

<?php
// src/Infrastructure/Http/Exception/DomainExceptionHandler.php

namespace App\Infrastructure\Http\Exception;

use InvalidArgumentException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class DomainExceptionHandler
{
    public function onKernelException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();

        if ($exception instanceof InvalidArgumentException) {
            $response = new JsonResponse([
                'error' => 'Validation failed',
                'message' => $exception->getMessage()
            ], Response::HTTP_BAD_REQUEST);

            $event->setResponse($response);
            return;
        }

        // Handle other domain exceptions
        if ($exception instanceof DomainException) {
            $response = new JsonResponse([
                'error' => 'Domain error',
                'message' => $exception->getMessage()
            ], Response::HTTP_UNPROCESSABLE_ENTITY);

            $event->setResponse($response);
        }
    }
}

Registering Exception Handler

# config/services.yaml
services:
    App\Infrastructure\Http\Exception\DomainExceptionHandler:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

Query Bus (CQRS)

Query Interface

<?php
// src/Application/Query/QueryInterface.php

namespace App\Application\Query;

interface QueryInterface
{
}

Query Handler

<?php
// src/Application/Query/GetUserQuery.php

namespace App\Application\Query;

use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\UserId;

readonly class GetUserQuery
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {
    }

    public function execute(UserId $id): ?array
    {
        $user = $this->userRepository->findById($id);

        if ($user === null) {
            return null;
        }

        return [
            'id' => $user->id()->value(),
            'email' => $user->email()->value(),
            'name' => $user->name(),
            'createdAt' => $user->createdAt()->format('c'),
            'isActive' => $user->isActive()
        ];
    }
}

Best Practices for Symfony

  1. Use Attributes: Leverage PHP 8 attributes for routing, validation, DI, and event listening.

  2. Keep Controllers Thin: Controllers should only handle HTTP concerns and delegate to application layer.

  3. Use DTOs: Separate request/response DTOs from domain entities.

  4. Validation at Boundaries: Validate input at the adapter layer (controllers), not in domain.

  5. Messenger for Async: Use Symfony Messenger for commands that need async processing.

  6. XML/Attribute Mapping: Prefer XML or PHP 8 attributes for Doctrine mapping over annotations.

  7. Service Autoconfiguration: Let Symfony autoconfigure your services, only override when necessary.

  8. Test with Real Container: Use KernelTestCase for integration tests with real DI container.

  9. Environment Variables: Use %env()% for configuration that changes per environment.

  10. Monolog for Logging: Use PSR-3 logger interface in domain, Monolog implementation in infrastructure.

plugins

CHANGELOG.md

context7.json

CONTRIBUTING.md

README_CN.md

README_ES.md

README_IT.md

README.md

tessl.json

tile.json