Laravel View Presenter

approximately 4 minutes of reading

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 fetched with() 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.


Words: 939
Published in: Laravel · PHP · Software Architecture
Last Revision: August 03, 2023

Related Articles   đź“š