Jul 7, 2026

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:

  1. End-to-end strict typing - the controller knows what the view expects, and the view knows what the controller provides.
  2. 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.
  3. 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(
        private 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 and update the namespace:

<?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(). This would function as a drop-in replacement for @extends('layouts.app') in Blade, where page views never need to think about the layout at all. 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(
        private readonly string $title,
        private readonly string $content,
    ) {}

    public function render(): string
    {
        $vite = app(Vite::class);
        $assets = $vite(['resources/css/app.css', 'resources/js/app.js']);

        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(
        private readonly string $title,
        private 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:

  1. 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.
  2. 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.
  3. 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