Presenters
Presenters transform QueryResponse objects into ViewModels. They isolate presentation logic from application logic, enabling multiple output formats from the same data.
Structure
Presenters implement PresenterInterface, whose contract is the getViewModel() method:
Each presenter stores its ViewModel in its own typed property and declares getViewModel() with the concrete return type:
final class ListBooksHtmlPresenter extends PresenterAbstract implements PresenterInterface
{
private ListBooksHtmlViewModel $htmlViewModel;
public function present(ListBooksResponse $response): self
{
$this->htmlViewModel = new ListBooksHtmlViewModel(
books: $response->books,
count: count($response->books),
can_create_book: $this->context?->userCan('book.create') ?? false
);
return $this;
}
#[Override]
public function getViewModel(): ListBooksHtmlViewModel
{
return $this->htmlViewModel;
}
}
Usage in Controllers
$queryResponse = $this->queryBus->dispatch(new ListBooksQuery());
$viewModel = $this->presenter
->withContext($context)
->present($queryResponse)
->getViewModel();
$html = $this->twig->render('ListBooks.html.twig', (array) $viewModel);
Presentation Context
The PresentationContextInterface provides user permissions to presenters for building permission-aware ViewModels.
Interface
Implementation
The demo application implements this interface by delegating to UserContext:
final readonly class PresentationContext implements PresentationContextInterface
{
public function __construct(private UserContext $userContext) {}
public function userCan(string $permission): bool
{
return $this->userContext->can($permission);
}
}
Injecting Context
Controllers pass context to presenters via withContext():
$viewModel = $this->presenter
->withContext($this->presentationContext)
->present($queryResponse)
->getViewModel();
Usage in Presenters
Context enables permission-based ViewModel properties:
$this->htmlViewModel = new ListBooksHtmlViewModel(
books: $response->books,
can_create_book: $this->context?->userCan('book.create') ?? false,
can_delete_books: $this->context?->userCan('book.delete') ?? false,
);
The null-safe operator (?->) handles cases where context is not set.
Source Files
src/Presentation/PresentationContextInterface.phpapp/demo/Shared/Presentation/PresentationContext.php
Responsibilities
Do: - Transform domain data to display format - Apply formatting (dates, currency) - Prepare CSS classes based on state - Include permission flags
Don't: - Fetch data from repositories - Modify domain state - Contain business logic
Badge Services
Badge services provide CSS class mapping for status-based display elements. They encapsulate the visual representation of domain states.
BookStatusBadgeService
final readonly class BookStatusBadgeService
{
public function __invoke(string $status): string
{
return match ($status) {
BookStatus::Available->value => 'bg-success',
BookStatus::Borrowed->value => 'bg-danger',
default => 'bg-dark',
};
}
}
LoanStatusBadgeService
Handles additional context like overdue status:
final readonly class LoanStatusBadgeService
{
public function __invoke(string $status, bool $isOverdue = false): string
{
if ($status === 'active' && $isOverdue) {
return 'bg-danger';
}
return match ($status) {
'active' => 'bg-warning text-dark',
'returned' => 'bg-success',
default => 'bg-secondary',
};
}
}
Usage in Presenters
Badge services are injected into presenters and used when building ViewModels:
$this->htmlViewModel = new BookViewModel(
statusBadgeClass: ($this->badgeService)($book->status),
// ...
);
Source Files
app/demo/Library/Presentation/Service/BookStatusBadgeService.phpapp/demo/Loan/Presentation/Service/LoanStatusBadgeService.php
Naming Conventions
{Name}HtmlPresenter- Web HTML output{Name}JsonPresenter- API JSON output{Name}BadgeService- Status to CSS class mapping- One presenter per view/endpoint
See Also
- Queries & Handlers - Transform QueryResponse
- RBAC Permissions - Permission-aware ViewModels