Skip to content

The presenter: the layer nobody implements

Most MVC PHP projects skip the Presenter. The controller fetches an entity, passes it to a template, and the template handles the rest: formatting dates, computing labels, picking CSS classes from status values. It feels pragmatic at first. It rarely stays that way.

The problem

Once display logic lives in templates, several things start to slip at once. Tests that should validate behavior end up asserting on HTML. The same data, when exposed through a JSON endpoint, needs a parallel formatting path because the template cannot be reused. Twig accumulates filters, macros, and conditionals that depend on domain state. The boundary between "what the application produced" and "how it should appear" disappears.

The deeper issue: the controller has handed a domain object to a layer that has no reason to know about the domain. The template needs strings, booleans, and formatted values. Giving it an entity forces the entity's API to leak into the view, and any change to the entity becomes a change to the template.

The mechanism

In Phexium, the controller dispatches a query through the QueryBus. The handler returns a QueryResponse holding raw data: entities, value objects, pagination metadata. Between the response and the view sits a Presenter, whose only job is to turn the Response into a ViewModel.

final readonly class ListBooksController extends AbstractHttpController implements ControllerInterface
{
    public function __construct(
        private QueryBusInterface $queryBus,
        private ListBooksHtmlPresenter $presenter,
        private Environment $twig,
        private ResponseBuilderInterface $responseBuilder,
    ) {}

    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $query = new ListBooksQuery(page: (int) ($request->getQueryParams()['page'] ?? 1));
        $queryResponse = $this->queryBus->dispatch($query);

        $context = $this->createPresentationContext($request);

        $viewModel = $this->presenter
            ->withContext($context)
            ->present($queryResponse)
            ->getViewModel();

        $html = $this->twig->render('ListBooks.html.twig', (array) $viewModel);

        return $this->responseBuilder
            ->withResponse($response)
            ->withStatus(200)
            ->withHtml($html)
            ->build();
    }
}

The Presenter is a small class with a single responsibility: take a Response, produce a ViewModel. It receives a PresentationContext through withContext(). The context carries information the formatting layer needs but the domain does not know about: display preferences, session state, the current user's capabilities, locale-specific options.

final class ListBooksHtmlPresenter extends PresenterAbstract implements PresenterInterface
{
    public function present(ListBooksResponse $response): self
    {
        $pagination = $response->pagination;

        $this->viewModel = new ListBooksHtmlViewModel(
            books: $response->books,
            count: $pagination->totalCount,
            page: $pagination->page,
            totalPages: $pagination->getTotalPages(),
            has_next_page: $pagination->hasNextPage(),
            has_previous_page: $pagination->hasPreviousPage(),
            can_create_book: $this->context?->userCan('book.create') ?? false,
            can_delete_book: $this->context?->userCan('book.delete') ?? false,
        );

        return $this;
    }

    #[Override]
    public function getViewModel(): ListBooksHtmlViewModel
    {
        return $this->viewModel;
    }
}

The ViewModel is a readonly DTO. Every property is exactly what the template needs, already typed, already named:

final readonly class ListBooksHtmlViewModel
{
    public function __construct(
        public array $books,
        public int $count,
        public int $page = 1,
        public int $totalPages = 1,
        public bool $has_next_page = false,
        public bool $has_previous_page = false,
        public bool $can_create_book = false,
        public bool $can_delete_book = false,
    ) {}
}

The template iterates over a collection and reads pre-computed booleans. No filter to format a date that the Presenter could have formatted. No conditional pulling data out of an entity. What remains in Twig is layout.

Trade-offs

More files. For each use case that has a view, you write one Presenter and one ViewModel. For a CRUD page, that means four or five extra classes you would not have in a controller-to-template setup.

The boundary also forces you to decide upfront what the view actually needs, which is more work than passing the entity and figuring it out in Twig. That is not pure overhead, but it is not free either.

It pays off when one of three things happens: the view needs to be tested without rendering HTML, the same data needs a second output format, or a new developer tries to understand what the template displays without reading the entity class.

What this enables

The Presenter is a plain class. You instantiate it with a fake PresentationContext, pass it a Response built from object mothers, and assert on the ViewModel:

it('flags create as allowed when the user has the capability', function (): void {
    $context = new FakePresentationContext(capabilities: ['book.create']);
    $response = ListBooksResponseMother::withBooks(BookMother::available());

    $viewModel = (new ListBooksHtmlPresenter())
        ->withContext($context)
        ->present($response)
        ->getViewModel();

    expect($viewModel->can_create_book)->toBeTrue();
});

No HTTP, no Twig, no DOM. The test runs in milliseconds and verifies exactly what the template will see.

Producing a JSON variant of the same use case is a second Presenter class that consumes the same Response. The handler, the query, and the domain stay untouched. The view and its formatting become an interchangeable plugin point.

You stop treating the Presentation layer as the place where everything ambiguous ends up. The Presenter is small enough to read in one screen, and named explicitly enough that you stop arguing whether display logic belongs in the controller, in the template, or in a helper file you will later regret.