Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizControllers should be thin. Models should not contain presentation logic. So where do you prepare the data that a complex view needs?
Consider a dashboard page that shows a user's recent orders, their subscription status, a list of recommended products, and some statistics. Without a View Model, the controller becomes a data-fetching mess:
// Bad: the controller is doing too much data preparation
public function index(Request $request): View
{
$user = $request->user();
$recentOrders = $user->orders()->latest()->limit(5)->get();
$subscription = $user->subscription;
$isTrialing = $subscription?->onTrial() ?? false;
$daysLeft = $isTrialing ? now()->diffInDays($subscription->trial_ends_at) : null;
$recommendedProducts = Product::recommended($user)->limit(8)->get();
$orderStats = [
'total_spent' => $user->orders()->sum('total'),
'order_count' => $user->orders()->count(),
'average_order' => $user->orders()->avg('total'),
];
$notifications = $user->unreadNotifications()->limit(10)->get();
return view('dashboard', compact(
'user', 'recentOrders', 'subscription', 'isTrialing',
'daysLeft', 'recommendedProducts', 'orderStats', 'notifications',
));
}
This controller is not handling business logic — it is preparing view data. That is a different responsibility, and it deserves its own class.
Another common anti-pattern is putting presentation logic on the model itself — methods like formattedPrice(), statusBadgeColor(), or displayName() on an Eloquent model. Models should represent data and relationships, not know how a specific page wants to display them. A product might show its price differently on an invoice than on a catalog page. That formatting logic belongs in a View Model, not on the model.
What Is a View Model?
A View Model is a class that prepares all the data a specific view needs. It encapsulates the queries, calculations, and transformations required to render a page. The controller creates the View Model, and the view consumes it.
namespace App\ViewModels;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Collection;
class DashboardViewModel
{
public function __construct(
private readonly User $user,
) {}
public function recentOrders(): Collection
{
return $this->user->orders()
->with('items.product')
->latest()
->limit(5)
->get();
}
public function isTrialing(): bool
{
return $this->user->subscription?->onTrial() ?? false;
}
public function trialDaysLeft(): ?int
{
if (! $this->isTrialing()) {
return null;
}
return now()->diffInDays($this->user->subscription->trial_ends_at);
}
public function recommendedProducts(): Collection
{
return Product::recommended($this->user)->limit(8)->get();
}
public function totalSpent(): float
{
return $this->user->orders()->sum('total');
}
public function orderCount(): int
{
return $this->user->orders()->count();
}
public function averageOrderValue(): float
{
return $this->user->orders()->avg('total') ?? 0;
}
public function unreadNotifications(): Collection
{
return $this->user->unreadNotifications()->limit(10)->get();
}
}
Now the controller is clean:
public function index(Request $request): View
{
return view('dashboard', [
'view' => new DashboardViewModel($request->user()),
]);
}
And the Blade template accesses data through the View Model:
<h2>Recent Orders</h2>
@foreach ($view->recentOrders() as $order)
<div>{{ $order->id }} — ${{ $order->total }}</div>
@endforeach
@if ($view->isTrialing())
<div class="alert">
Your trial ends in {{ $view->trialDaysLeft() }} days.
</div>
@endif
<div class="stats">
<span>Total Spent: ${{ number_format($view->totalSpent(), 2) }}</span>
<span>Orders: {{ $view->orderCount() }}</span>
<span>Average: ${{ number_format($view->averageOrderValue(), 2) }}</span>
</div>
Using Spatie's View Models Package
spatie/laravel-view-models adds a convenient base class. View Models that extend it can be returned directly from controllers:
composer require spatie/laravel-view-models
namespace App\ViewModels;
use Spatie\ViewModels\ViewModel;
class OrderIndexViewModel extends ViewModel
{
public function __construct(
private readonly User $user,
private readonly ?string $status = null,
) {}
public function orders(): LengthAwarePaginator
{
return $this->user->orders()
->when($this->status, fn ($q, $status) => $q->where('status', $status))
->with('items.product')
->latest()
->paginate(15);
}
public function statusOptions(): array
{
return OrderStatus::cases();
}
public function currentStatus(): ?string
{
return $this->status;
}
}
With Spatie's package, every public method becomes a variable in the view. The controller returns the View Model directly:
public function index(Request $request): OrderIndexViewModel
{
return new OrderIndexViewModel(
user: $request->user(),
status: $request->query('status'),
);
}
The view automatically receives $orders, $statusOptions, and $currentStatus as variables — one for each public method.
View Models for Forms
View Models are especially useful for forms that need dropdown data, defaults, and existing values:
class OrderEditViewModel extends ViewModel
{
public function __construct(
private readonly Order $order,
) {}
public function order(): Order
{
return $this->order;
}
public function statusOptions(): array
{
return OrderStatus::cases();
}
public function shippingAddresses(): Collection
{
return $this->order->user->addresses()
->orderBy('is_default', 'desc')
->get();
}
public function availableCoupons(): Collection
{
return Coupon::where('expires_at', '>', now())
->where('is_active', true)
->get();
}
}
Without a View Model, all of this data-fetching would clutter the controller's edit() method. Notice how OrderStatus is an enum — View Models pair naturally with enums to provide type-safe options for dropdowns and filters.
View Models vs. View Composers
Laravel also has View Composers — callbacks that inject data into a view every time it renders. They solve a different problem:
| View Model | View Composer | |
|---|---|---|
| Scope | One specific page or endpoint | Any view that matches a pattern |
| Created by | The controller, explicitly | The service provider, globally |
| Use case | Dashboard data, form dropdowns | Navigation menu, auth user, notifications |
| Testability | Unit test directly | Requires rendering the view |
View Composers are best for data that appears on every page — the logged-in user's name in the navbar, a global notification count, or footer links. If you find yourself registering a View Composer for a single view, use a View Model instead.
The two patterns complement each other. Use View Composers for global, shared data. Use View Models for page-specific data preparation.
Testing View Models
Because View Models are plain PHP classes, they are straightforward to test. No HTTP requests needed — just instantiate the class and call its methods:
it('returns the correct trial days remaining', function (): void {
$user = User::factory()
->has(Subscription::factory()->state([
'trial_ends_at' => now()->addDays(7),
]))
->create();
$viewModel = new DashboardViewModel($user);
expect($viewModel->isTrialing())->toBeTrue();
expect($viewModel->trialDaysLeft())->toBe(7);
});
it('returns null trial days when not trialing', function (): void {
$user = User::factory()->create();
$viewModel = new DashboardViewModel($user);
expect($viewModel->isTrialing())->toBeFalse();
expect($viewModel->trialDaysLeft())->toBeNull();
});
This is one of the main benefits of View Models over putting data preparation in controllers — you can test the logic in isolation without making HTTP requests or asserting against rendered HTML.
Organizing View Models
View Models sit at the end of the data flow: Controllers call Actions, Actions return domain objects or DTOs, and View Models transform that data for the view.
Place View Models in app/ViewModels, organized by feature or page:
app/ViewModels/
├── DashboardViewModel.php
├── Order/
│ ├── OrderIndexViewModel.php
│ ├── OrderShowViewModel.php
│ └── OrderEditViewModel.php
└── User/
├── UserProfileViewModel.php
└── UserSettingsViewModel.php
When to Use View Models
| Situation | Use View Model? |
|---|---|
| Simple page with one model | No — pass the model directly |
| Page with multiple data sources | Yes |
| Form with dropdown options | Yes |
| Dashboard with statistics | Yes |
| Inertia.js page with complex props | Yes |
| Data shared across every page | No — use a View Composer |
| Page that just lists one resource | Probably not |
The rule of thumb: if your controller method needs more than two lines of data preparation, extract it into a View Model. Your controllers stay thin, your views stay clean, and your data preparation logic is testable in isolation.
The View Model Checklist
- One View Model per page — name it after the view:
DashboardViewModel,OrderEditViewModel - Keep presentation logic off models — methods like
statusBadgeColor()orformattedPrice()belong in View Models, not on Eloquent models - Pass dependencies via the constructor — the controller creates the View Model with the data it needs
- Use View Composers for shared data — navigation, auth user, footer links. View Models are for page-specific data
- Test in isolation — instantiate the class and call methods directly. No HTTP requests needed
Summary
- A View Model is a class that prepares all the data a specific view needs — queries, calculations, and transformations live here, not in the controller.
- Keep presentation logic out of Eloquent models. Methods like
statusBadgeColor()orformattedPrice()belong in a View Model, not on the model itself. spatie/laravel-view-modelsturns public methods into view variables automatically and supports returning View Models directly from controllers.- View Composers inject data into every render of a view (navbar, footer). View Models prepare data for one specific page. Use both when appropriate.
- View Models are plain PHP classes, making them easy to unit test without HTTP requests or rendered HTML.
- Use a View Model when your controller needs more than two lines of data preparation. For simple pages with one model, pass the model directly.
References
- View Models in Laravel — Brent Roose
- Laravel View Models vs. View Composers — Brent Roose
- spatie/laravel-view-models — Spatie, GitHub
- Laravel View Models — Laravel News
- Adding View Models to a Laravel Project — David Llop
- Views: View Composers — Laravel Documentation
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz