- HTTP Request arrives at controller
- Controller creates/hydrates DTO from request
- Validator ensures DTO meets business rules
- Action executes business logic with validated DTO
- Controller transforms result to HTTP response
[HTTP Request] → [Controller] → [DTO + Validation] → [Action] → [Entity] → [Database]
↓
[HTTP Response]
Controllers should be mere orchestrators, not business logic containers. Their responsibilities are limited to:
- Receiving HTTP requests
- Delegating to appropriate actions
- Returning HTTP responses
#[Route('/new', name: 'app_todo_new', methods: ['GET', 'POST'])]
public function new(
Request $request,
CreateTodoAction $createTodoAction,
ValidatorInterface $validator
): Response {
// Controller only coordinates, doesn't implement business logic
if ($request->isMethod('POST')) {
// ... validate DTO
$createTodoAction->execute($dto); // Delegate to action
// ... return response
}
}Each action class has one job and does it well. This approach provides:
- Single Responsibility: Each action handles exactly one business operation
- Testability: Actions can be unit tested in isolation
- Reusability: Actions can be called from controllers, console commands, message handlers, etc.
- Readability: Intent is clear from the class name
class CreateTodoAction
{
public function execute(CreateTodoDto $dto): Todo
{
// Single, focused responsibility
$todo = new Todo();
$todo->setTitle($dto->title);
$todo->setDescription($dto->description);
$this->entityManager->persist($todo);
$this->entityManager->flush();
return $todo;
}
}DTOs serve as validated, typed data containers that:
- Validate input at the boundary of your application
- Document expectations through property types and validation constraints
- Decouple layers by not passing raw request data deep into your domain
class CreateTodoDto
{
#[Assert\NotBlank(message: 'Title is required.')]
#[Assert\Length(max: 255)]
public string $title;
public ?string $description = null;
}#[MapRequestPayload]: Automatic request body to DTO hydration- Symfony Validator: Declarative validation rules on DTOs
- Dependency Injection: Actions are services with autowired dependencies
- Separation of Concerns: Each class has a clear, single purpose
- Easy to Locate Code: Need to change how todos are created? Look in
CreateTodoAction - Predictable Structure: Consistent patterns across the application
- Team Collaboration: Clear boundaries prevent merge conflicts
- Feature Addition: New features follow established patterns
- Refactoring Safety: Small, focused classes are easier to refactor
- Multiple Entry Points: Same action can be used from:
- Web controllers
- API endpoints
- Console commands
- Message/event handlers
- Scheduled jobs
src/
├── Action/
│ └── Todo/
│ ├── CreateTodoAction.php # Creates new todos
│ ├── UpdateTodoAction.php # Updates existing todos
│ ├── DeleteTodoAction.php # Removes todos
│ └── ToggleTodoAction.php # Toggles completion status
├── Controller/
│ └── TodoController.php # Thin HTTP layer
├── Dto/
│ ├── CreateTodoDto.php # Input validation for creation
│ └── UpdateTodoDto.php # Input validation for updates
├── Entity/
│ └── Todo.php # Domain model
└── Repository/
└── TodoRepository.php # Data access layer
- PHP 8.2+
- Composer
- SQLite (default) or MySQL/PostgreSQL
# Clone the repository
git clone <repository-url>
cd symftodo
# Install dependencies
composer install
# Create database and run migrations
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
# Start development server
symfony server:startThis application serves as a reference implementation. Feel free to:
- Fork and adapt the patterns to your needs
- Suggest improvements via issues
- Share your experiences with this architecture
MIT License - Feel free to use this as a starting point for your projects.