Typed & Testable Views in PHP
Replace loose template variables with typed PHP view classes that enforce contracts between controllers and views, compose like React components, and unit test without booting a framework.
Introduction
I’ve worked with PHP frameworks and TypeScript component libraries, and I’ve come to deeply value the IDE experience that strict typing provides. When I use PHP template engines, the connection between controllers and views can disappear. I can’t tell what a view expects from the controller side, nor what the controller provides from the view side.
I recently noticed this same gap while building a project with a custom template engine. It hit me: could I build something in PHP that gives me the same typed, testable component experience I get from React?
Requirements
When thinking about this problem, I had some requirements for myself:
- End-to-end strict typing - the controller knows what the view expects, and the view knows what the controller provides.
- Unit-testable views - render and assert against view output without booting a router or hitting a route which in turn reduces the amount of time for test runners.
- Static Analysis Compatible - standard views leave analyzers like PHPStan blind to variable sources, triggering false positives on undefined variables or object properties. Docblocks patch the gap but create no enforceable contract between controller and view.
Base View
We’ll start with a lightweight abstract base that has a required
render() method and a helper for capturing output.
<?php
namespace MyApp\Components;
use Closure;
abstract class View
{
public function __toString(): string
{
return $this->render();
}
/**
* Captures an inline template block with automatic output buffering.
*
* @param Closure $template Closure containing the template markup
* @return string The captured template HTML
*/
protected function capture(Closure $template): string
{
ob_start();
$template->call($this);
return ob_get_clean();
}
/**
* Child classes must implement their render logic.
*
* @return string The rendered HTML
*/
abstract public function render(): string;
}
Example View
With the base in place, we can write a concrete view. The
constructor is where the typing pays off: every dependency is
explicit. For the example below, it leverages a
Layout component and renders the content in a separate
method. You can also make subviews where the content could go
directly in the render() method and not require
additional methods. If your view does not require any outside data,
you can completely omit the constructor.
<?php
namespace MyApp\Views\Home;
use Core\Lib\QueryResult;
use MyApp\Components\{
Card,
CardOptions,
Layout,
LayoutOptions,
View
};
class HomePage extends View
{
public function __construct(
public readonly QueryResult $articles
) {
}
public function render(): string
{
return $this->capture(function () {
Layout::create(
new LayoutOptions(
page_header: 'Home',
content: $this->content()
)
);
});
}
private function content(): string
{
return $this->capture(function () {
foreach ($this->articles as $article) {
Card::create(
new CardOptions(
title: $article->title,
content: $article->abstract
)
);
}
});
}
}
Example Controller
Back in the controller, the view’s constructor becomes a contract. You build the data, pass it in, and if the types don’t align, PHP tells you immediately in your IDE. There is no need to open the view file.
<?php
namespace MyApp\Controllers\Home;
use Core\Lib\QueryOrder;
use MyApp\Controllers\AppController;
use MyApp\Models\Article;
use MyApp\Views\Home\HomePage;
class HomePageController extends AppController
{
/**
* Prepares data for the home page view.
*
* Fetches and sets the data
*
* @return HomePage
*/
public function get(): HomePage
{
global $app;
$db = new Article();
$db->select('title, abstract');
$db->where([
['created_by', '=', $app->user->id]
]);
$db->orderBy('date_created', QueryOrder::desc);
$articles = $db->findAll();
return new HomePage($articles);
}
}
Testing
The PHP Unit example below shows that you can load model data into the view and then run tests on the output. It is very straight-forward to then test any view in isolation since its dependencies are clearly defined.
<?php
namespace Tests;
use Core\Lib\QueryResult;
use MyApp\Models\Article;
use MyApp\Views\HomePage;
use PHPUnit\Framework\TestCase;
class HomePageTest extends TestCase
{
protected function testRendersArticles(): void
{
$article1 = new Article([
'title' => 'Article 1',
'abstract' => 'Abstract 1'
]);
$article2 = new Article([
'title' => 'Article 2',
'abstract' => 'Abstract 2'
]);
$articles = new QueryResult([$article1, $article2], 2);
$view = new HomePage($articles);
$this->assertStringContainsString('Article 1', $view);
$this->assertStringContainsString('Article 2', $view);
}
}
Using This Pattern in Laravel
This pattern works naturally in Laravel with no packages or special
configuration. Drop the base View class into
app/Support/View.php.
<?php
namespace App\Support;
use Closure;
abstract class View
{
public function __toString(): string
{
return $this->render();
}
protected function capture(Closure $template): string
{
ob_start();
$template->call($this);
return ob_get_clean();
}
abstract public function render(): string;
}
Create your views wherever makes sense for your project. A common
choice is resources/views/ with a PSR-4 mapping added
to composer.json:
"autoload": {
"psr-4": {
"App\\": "app/",
"App\\Views\\": "resources/views/"
}
}
The following code sets up a layout component. You can use it
directly in each typed view by instantiating it and passing content
to it. Alternatively, you could create a PageView base
class that extends View and automatically wraps the
content in the Layout which implements title() and
content() methods instead of render().
That said, I prefer including the layout explicitly in each view as
it makes it trivial to swap between different layouts like a guest
layout vs an authenticated one.
<?php
namespace App\Views\Components;
use App\Support\View;
use Illuminate\Foundation\Vite;
class Layout extends View
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly array $assets = []
) {}
public function render(): string
{
$vite = app(Vite::class);
$assets = $vite(array_merge(['resources/css/app.css', 'resources/js/app.js'], $this->assets));
return $this->capture(function () use ($assets) {
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($this->title) ?></title>
<?= $assets ?>
</head>
<body>
<header>
<nav>
<a href="/">My Site</a>
<a href="/articles">Articles</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main>
<h1><?= $this->title; ?></h1>
<?= $this->content ?>
</main>
</body>
</html>
<?php
});
}
}
Page views compose with the layout by passing their content into it:
<?php
namespace App\Views;
use App\Support\View;
use App\Views\Components\Layout;
use Illuminate\Database\Eloquent\Collection;
class HomePage extends View
{
public function __construct(
public readonly string $title,
public readonly Collection $articles,
) {}
public function render(): string
{
return $this->capture(function () {
echo new Layout(
title: $this->title,
content: $this->content()
);
});
}
private function content(): string
{
return $this->capture(function () {
?>
<ul>
<?php foreach ($this->articles as $article) { ?>
<li><?= e($article->title) ?></li>
<li><?= e($article->content) ?></li>
<?php } ?>
</ul>
<?php
});
}
}
Laravel’s router already handles objects with
__toString() as responses, so your controller can
return the view directly:
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Views\HomePage;
class PageController extends Controller
{
public function home(): HomePage
{
return new HomePage(
title: 'Welcome',
articles: Article::latest()->get(),
);
}
}
No service provider, no Blade, no view composers. The controller’s return type is the contract. If you refactor the view’s constructor and forget to update a controller, PHP fails at compile time and your IDE will let you know there is an error.
Editor Snippet
While each view has some boilerplate, you can scaffold one with an editor snippet. Here’s a PHP VS Code snippet for a new view:
{
"View": {
"prefix": "view",
"body": [
"namespace App\\Views;",
"",
"use App\\Support\\View;",
"",
"class $1 extends View",
"{",
" public function render(): string",
" {",
" return \\$this->capture(function() {",
" });",
" }",
"",
" private function content(): string",
" {",
" return \\$this->capture(function() {",
" });",
" }",
"}"
]
}
}
Type view, hit tab, and name your class.
Conclusion
This pattern satisfies all three requirements I set out at the start:
- End-to-end strict typing - The view’s constructor is the contract. The controller builds typed arguments and passes them in; if the types don’t align, PHP errors before a single line of HTML is rendered.
-
Unit-testable views - Every view is a class with
a
render()method, so any page can be tested in isolation by instantiating it with test data and asserting against the output without router bootstrap required. - PHPStan Compatible - Every variable the template touches arrives through a typed constructor parameter. PHPStan sees the full type graph, and false positives on undefined variables disappear.
None of this requires a new language or a custom template syntax. It is plain PHP with strict types, a small abstract base, and a bit of discipline around constructor signatures. If you are already comfortable with typed frontend components, this pattern should feel familiar.
✌️ Matt