MVC vs Clean Architecture: Where Your Code Actually Goes
Most PHP developers start with MVC. You know where things go: controllers handle requests, models handle data, views handle output. Then the project grows, and the model becomes 800 lines of ORM queries, validation rules, and business logic fighting for space.
Clean Architecture doesn't add complexity for fun. It splits responsibilities that MVC bundles together. Here's the concrete mapping, with code.
The Controller Problem
In a typical MVC framework, a controller does this:
// MVC Controller
public function store(Request $request)
{
$validated = $request->validate(['title' => 'required']);
$book = Book::create($validated);
return redirect('/books');
}
HTTP handling, validation, persistence, and response, all in one method. In Phexium, the controller handles HTTP only:
// Phexium Controller
public function create(
ServerRequestInterface $request,
ResponseInterface $response,
): ResponseInterface
{
$data = (array) $request->getParsedBody();
$command = new CreateBookCommand(
$this->idGenerator->generate(),
$data['title'] ?? '',
$data['author'] ?? '',
$data['isbn'] ?? '',
);
$this->commandBus->dispatch($command);
return $response->withHeader('Location', '/books')->withStatus(302);
}
The controller creates a Command, a pure DTO, and dispatches it through a bus. It doesn't know what happens next.
The Command and Handler
The Command carries data. Nothing else:
final readonly class CreateBookCommand implements CommandInterface
{
public function __construct(
public IdInterface $id,
public string $title,
public string $author,
public string $isbn,
) {}
}
The Handler orchestrates the use case:
final readonly class CreateBookHandler implements CommandHandlerInterface
{
public function __construct(
private BookRepository $bookRepository,
private EventBusInterface $eventBus,
private ClockInterface $clock,
private IdGeneratorInterface $idGenerator,
) {}
public function __invoke(CreateBookCommand $command): void
{
$book = new Book(
$command->id,
Title::fromString($command->title),
Author::fromString($command->author),
ISBN::fromString($command->isbn),
);
$this->bookRepository->save($book);
$this->eventBus->dispatch(
new BookCreatedEvent(
$this->idGenerator->generate(),
$this->clock->now(),
$book,
),
);
}
}
The Handler creates the entity using Value Objects that validate themselves, saves through a repository interface, and dispatches a domain event. No HTTP awareness.
The Model Becomes Three Things
In MVC, the Model is your Active Record class. It handles persistence, validation, and business rules. In Clean Architecture, these split into three distinct pieces.
The Entity carries business rules:
final class Book extends EntityAbstract implements EntityInterface
{
public function markAsBorrowed(): void
{
if (!$this->status->canBeBorrowed()) {
throw BookNotAvailableException::forBorrowing($this->id);
}
$this->status = BookStatus::Borrowed;
}
}
The Value Object validates at construction:
final readonly class ISBN
{
private function __construct(public string $value) {}
public static function fromString(string $isbn): self
{
// validation here, if invalid, it throws
return new self($isbn);
}
}
The Repository Interface defines the persistence contract in the Domain:
interface BookRepository
{
public function findById(IdInterface $id): ?Book;
public function save(Book $book): void;
public function delete(Book $book): int;
}
The implementation lives in Infrastructure:
final class SqliteBookRepository extends AbstractBookRepository
{
public function __construct(SqliteDriver $driver, BookMapper $mapper)
{
parent::__construct($driver, $mapper);
}
}
The View Gets a Pipeline
MVC views receive raw model data. In Phexium, the data goes through a Presenter that shapes it into a ViewModel:
final class ListBooksHtmlPresenter extends PresenterAbstract
{
public function present(ListBooksResponse $response): self
{
$this->viewModel = new ListBooksHtmlViewModel(
books: $response->books,
count: $response->pagination->totalCount,
page: $response->pagination->page,
can_create_book: $this->context?->userCan('book.create') ?? false,
);
return $this;
}
}
The ViewModel is a flat, template-ready structure. The template doesn't call methods or check permissions, everything is pre-computed.
The Practical Mapping
| MVC | Clean Architecture | What Changed |
|---|---|---|
| Controller | Controller + Command + Handler | HTTP separated from orchestration |
| Model | Entity + Value Object + Repository | Domain isolated from persistence |
| View | Presenter + ViewModel + Template | Explicit data transformation |
| - | Bus (Command/Query) | Dispatch with middleware |
| - | Domain Events | Cross-cutting communication |
What You Get
Testability changes fundamentally. You test your Entity without a database. You test your Handler with an in-memory repository. You test your Controller without business logic. Each layer is independently verifiable.
Adding a feature means creating a new Command and Handler. Existing code stays untouched, Open/Closed Principle applied structurally, not by discipline.
The trade-off is real: more files, more indirection, a steeper initial learning curve. For a blog or a CRUD admin panel, MVC is simpler and faster. Phexium targets projects where business rules matter, where you need to swap infrastructure, and where test coverage is non-negotiable.