Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Presenter Laravel Package

laracasts/presenter

A lightweight presenter layer for Laravel that wraps your models and exposes clean, dedicated methods for formatting and displaying their data. Keep view logic out of models and controllers, with simple, testable presenter classes.

View on GitHub
Deep Wiki
Context7

Getting Started

Install with a single Composer line — there is no service provider, no config file, no migration, and no published assets:

composer require laracasts/presenter

Where to look first. Skip the README and read the source: vendor/laracasts/presenter/src/Presenter.php and PresentableTrait.php are ~40 lines combined. PresentableTrait::present() instantiates your presenter with the model and memoizes it on the model instance; Presenter proxies __get/__call/__isset to $this->entity and calls a same-named method before falling back to the attribute. Everything else is convention. That five-minute read prevents 90% of the confusion later.

Create the presenter. Formatting methods return display-ready values (strings, ints, arrays — not markup):

<?php

namespace App\Presenters;

use Illuminate\Support\Number;
use Illuminate\Support\Str;
use Laracasts\Presenter\Presenter;

class UserPresenter extends Presenter
{
    public function fullName(): string
    {
        return trim("{$this->first_name} {$this->last_name}");
    }

    public function initials(): string
    {
        return Str::of($this->fullName())
            ->explode(' ')
            ->map(fn ($part) => Str::substr($part, 0, 1))
            ->implode('');
    }

    public function joinedAt(): ?string
    {
        return $this->created_at?->format('M j, Y');
    }

    public function planLabel(): string
    {
        return Number::currency($this->monthly_cents / 100, 'USD') . ' / mo';
    }
}

Wire it to the model. Two lines, no boot method:

use Laracasts\Presenter\PresentableTrait;

class User extends Authenticatable
{
    use PresentableTrait;

    protected $presenter = \App\Presenters\UserPresenter::class;
}

First use case. Pick the single most-repeated formatting expression in your Blade files — usually {{ $user->first_name }} {{ $user->last_name }}, a currency division, or a ->format('m/d/Y') — and move only that. Verify in Tinker before touching templates:

App\Models\User::first()->present()->fullName();

Then replace the Blade expression with {{ $user->present()->fullName() }}. If you cannot name three places that currently duplicate a format, this package is not the right tool yet — defer it.

Implementation Patterns

One presenter per model, methods return primitives. The presenter is a view-model, not a domain object. Return string, int, bool, or a small array; let Blade do the escaping. If you must return HTML (Markdown rendering, icon SVG), name the method …Html() so the {!! !!} in the template is obviously deliberate.

Attribute proxying. Inside a presenter, $this->first_name resolves through __get to $this->entity->first_name, including relations and Carbon casts. Two practical rules:

  • Use $this->entity->x when a presenter method name collides with a column (name(), status()), because the method always wins over the attribute.
  • Never reach into the container or repositories from a presenter. If it needs a database call, it belongs in the controller/query, not the presenter.

Call with parentheses in templates. Both $user->present()->fullName() and $user->present()->fullName work (the latter through __get), but only the explicit call is greppable. In a codebase that will grow to dozens of presenters, "find all usages of fullName" is worth the two characters.

Conditional/derived display. This is where presenters pay for themselves — the branching that used to live in Blade:

public function statusBadge(): array
{
    return match ($this->status) {
        'paid'     => ['label' => 'Paid',     'class' => 'bg-green-100 text-green-800'],
        'refunded' => ['label' => 'Refunded', 'class' => 'bg-amber-100 text-amber-800'],
        default    => ['label' => 'Pending',  'class' => 'bg-gray-100 text-gray-700'],
    };
}
@php($badge = $order->present()->statusBadge())
<span class="rounded px-2 {{ $badge['class'] }}">{{ $badge['label'] }}</span>

Loops: bind the presenter once. present() already memoizes the instance per model, so this is a readability change, not a performance one:

@foreach ($users as $user)
    @php($p = $user->present())
    <td>{{ $p->fullName() }}</td>
    <td>{{ $p->joinedAt() }}</td>
@endforeach

Do not reassign the loop variable to the presenter ($user = $user->present()) — you lose access to the model's attributes and relations, and the next person who needs $user->id is stuck.

Shared formatting helpers via a base presenter. One presenter per model does not mean copy-pasted money/date code:

namespace App\Presenters;

use Laracasts\Presenter\Presenter as BasePresenter;

abstract class Presenter extends BasePresenter
{
    protected function money(int $cents, string $currency = 'USD'): string
    {
        return \Illuminate\Support\Number::currency($cents / 100, $currency);
    }

    protected function excerpt(?string $text, int $limit = 140): string
    {
        return \Illuminate\Support\Str::limit(strip_tags((string) $text), $limit);
    }
}

Multi-surface reuse. The same presenter method serves Blade, mail templates, PDF exports, and admin panels — that is the actual product argument for the package. For JSON APIs, keep the split explicit: presenters produce human-readable strings, API Resources produce machine-readable values.

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'name'      => $this->present()->fullName(),        // display contract
            'joined_at' => $this->created_at?->toIso8601String(), // machine contract
        ];
    }
}

If you adopt this, write the rule down in the repo: presenters are for humans; timestamps and IDs stay raw/ISO.

Relations and N+1 — decide how the presenter counts. Two shapes, very different query behaviour:

// Reads the loaded collection. Zero queries if eager-loaded, N+1 if not.
public function commentCount(): int
{
    return $this->comments->count();
}

// Always one query — still N+1 across a list of 50.
public function commentCount(): int
{
    return $this->entity->comments()->count();
}

The safe default is withCount('comments') in the query plus public function commentCount(): int { return (int) $this->comments_count; }. Make "eager load everything a presenter touches" an explicit convention, not a code-review hope.

Collection helpers. $users->map->present() gives you a collection of presenters if you need to build an array payload; for ordinary rendering you don't need it at all.

Testing without the database. This is the cheapest quality win in the package — the presenter has no container dependency:

it('formats a full name', function () {
    $user = new User(['first_name' => 'Jane', 'last_name' => 'Doe']);

    expect((new UserPresenter($user))->fullName())->toBe('Jane Doe');
});

No RefreshDatabase, no HTTP, no factory. Keep relation-free formatting in pure methods so this stays true; reserve feature tests for presenters that read loaded relations.

Gotchas and Tips

present() throws if $presenter is missing or the class doesn't exist. A PresenterException is raised when the trait is present but no presenter class is configured. Don't add the trait to a shared base model with a null $presenter — every model that doesn't opt in will explode the moment something calls present().

Property-name collision. The trait relies on a $presenter property on the model. If a table has a presenter column, be aware the class property and the attribute are different things; access the column explicitly through Eloquent and don't name a presenter method presenter().

Static analysis is blind to the magic. PHPStan/PHPStorm will flag $this->first_name inside a presenter as an undefined property. Fix it at the class level:

/**
 * @mixin \App\Models\User
 */
class UserPresenter extends Presenter
{
    // ...
}

@mixin resolves the proxied attributes and methods. If you prefer explicitness, declare @property-read string $first_name etc. Either way, gate CI at a fixed PHPStan level — an untyped presenter layer degrades into unstructured PHP within a sprint. (PHP 8.2 dynamic-property deprecations are not an issue here because __get/__set are defined.)

Method shadows attribute — intentionally. Defining public function name() means $presenter->name returns the formatted value, not the column. That is the package's override mechanism and it's useful (e.g. masking an email), but it must be a conscious choice. $this->entity->name always gets you the raw value.

The real production risk is N+1, not bugs. Presenter methods read like free accessors, and $this->orders->sum('total') inside a Blade loop is a silent query bomb. Test it: DB::enableQueryLog() or assertQueryCount on a list endpoint after introducing a presenter method that touches a relation.

Accessors vs presenters — pick one per value and document it. A sensible split: Attribute casts/accessors for the canonical value (used by queries, API, business logic); presenters for the display variant. If full_name exists as both an accessor and a presenter method, you now have two ways to be wrong. This is the single convention that determines whether the package helps or hurts.

Presenters are not queryable. You can't where('full_name', …), sort by, or paginate on a presenter value. Anything users search or sort by must remain a real column (or a generated column). Reach for presenters on the read/render path only.

No authorization, no business rules, no writes. canBeDeleted(), isOverdue(), and "apply discount" belong in a policy and a domain service. A presenter that mutates or persists is a bug factory, and it makes the pure-unit test above impossible.

Null-safety is your responsibility. Unsaved models have null created_at, optional relations return null, and a presenter that throws inside a Blade loop takes down the whole page. Return ''/null and type-hint ?string. Never let a presenter be the place an exception surfaces.

Serialization does not include your methods. json_encode($presenter), ->toArray(), and array access proxy the underlying entity's array — fullName will not appear. If you want presenter output in a payload, build the array explicitly in the resource or override toArray(). Don't assume (array) $presenter does what you want.

Debugging. get_class_methods($user->present()) lists what's callable; $presenter->toArray() gives you the entity data; dd($user->present()) dumps the whole presenter (the entity is a protected property, so it shows up there). If a method appears not to fire, check for a name collision with a column.

Instance caching. present() memoizes one presenter per model instance for that request. Heavy formatting is therefore computed once per model even if a template calls it five times — good — but it also means a presenter holds a live reference to the model, so mutations later in the request are visible. Presenters are never serialized or queued; don't try to pass one to a job.

Extension points. Override __get/__call/__isset in your App\Presenters\Presenter base if you want automatic Carbon formatting, translated fallbacks, or a __() wrapper. __call proxies to the entity, so $presenter->posts() reaches the relation/query builder — convenient, and exactly how N+1 sneaks in.

Pin ^0.2 and know the exit. Release history is a monotonic sequence of "add Laravel N support" bumps (5 → 13) with no feature releases; the API has been functionally frozen for years. That's fine for a two-file abstraction, but treat it as vendored-in-spirit: write down that copying Presenter.php and PresentableTrait.php into app/Support/ and removing the dependency is an afternoon's work. Do the migration before a framework major upgrade, not during one.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk