The idea behind the View-Presenter pattern is to separate model's display logic from the data it represents. In other words neither a model, nor a view should know anything about how particular model's instance should be rendered. In this article I will guide you on how to create a basic structure for the pattern and how to consume it in an efficient way.
Defining model
To keep everything simple, let's create a dummy Person
model which contains the following attributes:
first_name
last_name
website_url
This can be represented with a migration:
Schema::create('people', function (Blueprint $table) {
$table->uuid('uuid');
$table->string('first_name');
$table->string('last_name');
$table->string('website_url')->nullable();
$table->timestamps();
});
Consuming model
At this stage we want to render person's full name, but to make it more fancy, let's render it as HTML with a link to person's website (if applicable). In a traditional way your blade template would look like:
@if(empty($person->website_url))
<p>{{ $person->first_name }} {{ $person->last_name }}</p>
@else
<p><a target="_blank" href="{{ $person->website_url }}">{{ $person->first_name }} {{ $person->last_name }}</a></p>
@endif
Pretty messy. We have ended up with five lines of code and an if / else
statement. Repeating this approach along the way would bloat your views with a lot of HTML. Now how about this example:
<p>{!! $person->fullNameWithLinkedWebsite() !!}</p>
Clean, narrative and it's just a single line of code. Follow the rest of this article to learn how to achieve this.
Building presentation layer
The core logic will be based upon two main classes (one abstract and one concrete) and a single dedicated class for each model you want to decorate with the presentation logic.
Step 1 - Create AbstractPresenter
class in App\Http\Presenters
.
<?php
namespace App\Http\Presenters;
use Illuminate\Database\Eloquent\Model;
abstract class AbstractPresenter
{
protected ?Model $object;
public function __construct(
?Model $object = null
) {
$this->object = $object;
}
public function set(Model $object): AbstractPresenter
{
$this->object = $object;
return $this;
}
public function __get(string $property)
{
if (method_exists($this, $property)) {
return $this->{$property}();
}
return $this->object->{$property};
}
}
Step 2 - Create Presenter
class in App\Http\Presenters
.
<?php
namespace App\Http\Presenters;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use ReflectionClass;
class Presenter
{
/**
* Array of Eloquent models that should not be wrapped in the presenter class.
*
* @var array
*/
private array $excluded = [];
public function model(Model $model)
{
foreach ($this->excluded as $excludedModel) {
if ($model instanceof $excludedModel) {
return $model;
}
}
$this->decorateRelations($model);
$presenterClass = $this->getPresenterClass($model);
if (!class_exists($presenterClass)) {
return $model;
}
return (new $presenterClass)->set($model);
}
/**
* @notice
*
* Do not type-hint of $collection argument here as it might be either:
* Collection or LengthAwarePaginator.
*
* @param $collection
* @return Collection
*/
public function collection($collection): Collection
{
$new = clone $collection;
if ($new instanceof LengthAwarePaginator) {
$new = collect($new->items());
}
if ($new instanceof Collection) {
foreach ($new as $key => $item) {
if ($item instanceof Model) {
$item = $this->model($item);
}
if ($item instanceof Collection) {
$item = $this->collection($item);
}
$new->put($key, $item);
}
}
return $new;
}
/**
* Decorates all relationships of the given Model.
*
* @param Model $model
*
* @return Model
*/
protected function decorateRelations(Model $model): Model
{
foreach ($model->getRelations() as $relationName => $relationModel) {
// decorate related model
if ($relationModel instanceof Model) {
$relationModel = $this->model($relationModel);
}
// decorate related collection
if ($relationModel instanceof Collection) {
$relationModel = $this->collection($relationModel);
}
$model->setRelation($relationName, $relationModel);
}
return $model;
}
/**
* Returns corresponding Presenter class, based on given Model's class name.
*
* @param Model $model
*
* @return string
*/
private function getPresenterClass(Model $model): string
{
return __NAMESPACE__ . '\\' . (new ReflectionClass($model))->getShortName() . 'Presenter';
}
}
Step 3 - Create a dedicated presenter class for our Person
model. Once you study above classes, you know that we have to follow a naming pattern and name our dedicated class as PersonPresenter
. Create this class in the same location as the other two classes (App\Http\Presenters
).
<?php
namespace App\Http\Presenters;
class PersonPresenter extends AbstractPresenter
{
public function fullNameWithLinkedWebsite(): string
{
$website = $this->object->website_url;
$fullName = sprintf('%s %s', $this->object->first_name, $this->object->last_name);
if (empty($website)) {
return $fullName;
}
return sprintf('<a target="_blank" href="%s">%s</a>', $website, $fullName);
}
}
Calling presentation layer
The last step is to glue everything together. In a dedicated controller all you have to do is to inject the Presenter
class. Not the PersonPresenter
! Just Presenter
(the one you have created in Step 2).
<?php
namespace App\Http\Controllers;
use App\Http\Presenters\Presenter;
class PeopleController extends Controller
{
private Presenter $presenter;
public function __construct(
Presenter $presenter
) {
$this->presenter = $presenter;
}
// cut (…)
Now we fetch a single Person
from a database.
$johnDoe = Person::where('first_name', '=', 'john')->first();
Finally lets decorate $johnDoe
by calling:
$johnDoePresented = $this->presenter->model($johnDoe);
dump($johnDoePresented->fullNameWithLinkedWebsite());
What if
Person
is fetchedwith()
multiple relations or instead of fetching a single model, we fetch multiple people?
This logic got you covered. Until all of the (related) models have their corresponding presenter classes defined, the recursion used within the Presenter
class will decorate all nested models and / or collections of models. Neat, huh?
To decorate a collection of models simply fetch it and call the collection()
method.
$people = Person::all();
$peoplePresented = $this->presenter->collection($people);
foreach ($peoplePresented as $personPresented) {
dump($personPresented->fullNameWithLinkedWebsite());
}
I hope you find this article useful.