Skip to content

feat: closure-based view system#6557

Open
gharlan wants to merge 6 commits into
6.xfrom
claude/view-system
Open

feat: closure-based view system#6557
gharlan wants to merge 6 commits into
6.xfrom
claude/view-system

Conversation

@gharlan

@gharlan gharlan commented Jun 16, 2026

Copy link
Copy Markdown
Member

Introduces a typed, statically analysable view layer in src/View, alongside the existing Fragment system (which is left untouched — no fragments are converted in this PR). It is a first version; details may still change before the 6.0 release.

Motivation

The current Fragment system passes an untyped var-bag into a template that runs in a $this scope (registered as a PHPStan/Psalm universal object crate), so nothing about a fragment's inputs is statically checked — call sites don't know which vars exist, which are required or what types they have, templates need @var/@psalm-scope-this boilerplate, and the optional escape flag is opaque to taint analysis.

What's in here

  • Html — typed safe-HTML value object, itself a Renderable. Html::from() escapes a plain string / renders any Renderable; Html::raw() wraps trusted markup; Html::capture() captures a closure's output so callers can write real inline HTML. The escaping decision lives in the type, not a runtime flag.
  • Renderable / RendersView / HasView — a class renders a co-located ClassName.view.php: a file that returns a typed, inert static closure receiving the whole object. render(): Html, no $this/scope-this magic, fully type-checked, runtime-enforced.
  • Renderer — internal engine that requires the view file and invokes the closure inside an output buffer (cleaned up on throw).
  • ViewResolver — identity-based override: a project or addon can replace any class's view by FQCN (ViewResolver::override(Foo::class, $file)), last registration wins.

Content properties take Renderable, so sub-components can be passed directly without calling ->render(). "Slots" are covered by typed optional Renderable properties + named arguments (no dedicated slot runtime). The project starter's DefaultTemplate ships a co-located DefaultTemplate.view.php as the intended authoring pattern.

Example

A component is a small typed class plus a co-located view file (illustrative — this PR ships the base; a real use is the project starter's DefaultTemplate):

Badge.php — the constructor is the statically-checked contract:

namespace Redaxo\Core\View\Component;

use Redaxo\Core\View\Renderable;
use Redaxo\Core\View\RendersView;

final class Badge implements Renderable
{
    use RendersView;

    public function __construct(
        public string $label,
        public string $type = 'default',
    ) {}
}

Badge.view.php — co-located (same base name), returns a typed closure that receives the object:

<?php

use Redaxo\Core\View\Component\Badge;

use function Redaxo\Core\View\escape;

return static function (Badge $badge): void { ?>
    <span class="label label-<?= escape($badge->type) ?>">
        <?= escape($badge->label) ?>
    </span>
<?php };

Call site — typed, named arguments, no var-bag:

echo new Badge(label: 'New', type: 'info')->render();

$badge->label/$badge->type are fully analysed in the view, and escape() keeps output safe. Since render() returns Html (which is Renderable), a Badge can also be passed straight into another component's content property — no manual ->render():

echo new Panel(title: 'Status', body: new Badge(label: 'New', type: 'info'))->render();

Deliberately out of scope (follow-ups)

  • Converting the existing fragments (tracked separately on claude/view-system-fragments; will likely be restructured conceptually rather than translated 1:1).
  • An HtmlAttributes helper for attribute handling in views.
  • A slot runtime for partial view overrides, multi-view modules (input/output), and override auto-discovery — only if a real need shows up.

Verification

Green under phpunit, php-cs-fixer, phpstan (level 6) and psalm/taint. Covered by tests in tests/View (Html, Renderer, RendersView, ViewResolver, slot pattern).

🤖 Generated with Claude Code

A typed, statically analysable view layer in src/View, alongside the existing
Fragment system (which is left untouched — no fragments are converted here):

- Html: typed safe-HTML value object and itself a Renderable — Html::from()
  escapes plain strings / renders any Renderable, Html::raw() wraps trusted
  markup, Html::capture() captures a closure's output for inline HTML.
- Renderable / RendersView / HasView: a class renders a co-located
  ClassName.view.php (a file returning a typed, inert `static` closure); the
  whole object is handed to the view. render(): Html, no $this/scope-this magic.
- Renderer: internal engine that requires the view file and invokes the closure
  in an output buffer (cleaned up on throw).
- ViewResolver: identity-based override — a project/addon can replace any class's
  view by FQCN (ViewResolver::override(Foo::class, $file)), last registration wins.

Content properties take Renderable, so callers can pass sub-components directly
without ->render(); slots are covered by typed optional Renderable properties +
named arguments. The project starter's DefaultTemplate ships a co-located
DefaultTemplate.view.php as the intended authoring pattern. Test fixtures live in
tests/View/Fixtures.

Verified green under phpunit, php-cs-fixer, phpstan and psalm/taint.
@rex-bot rex-bot added the feature Additional functionality label Jun 16, 2026
@gharlan gharlan added this to the REDAXO 6.0 milestone Jun 16, 2026
gharlan and others added 4 commits June 16, 2026 23:44
Addresses PR review feedback:

- mark Html::raw() as an HTML taint sink (@psalm-taint-sink html): it is the
  explicit trust boundary, so feeding tainted input is now reported by the taint
  analysis (the in-view echo is severed from taint tracking by output buffering,
  so this is where it is caught). Html::from() escapes/renders and needs no sink.
- add explicit Closure signatures (Closure(): void / Closure(mixed...): void) so
  the code is ready for phpstan's missingType.callable
- Renderer::capture() uses Type::string(ob_get_clean()) instead of a soft cast
- reword the Renderable docblock (drop the speculative templates/modules note)
- trim the DefaultTemplate comment

Green: phpunit, php-cs-fixer, phpstan (incl. checkMissingCallableSignature on
src/View), psalm/taint.
Use Closure():void / Closure(mixed...):void in PHPDoc (no space before the return
type), independent of code-level spacing.
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= escape($content->article->name) ?> – <?= escape(Core::getServerName()) ?></title>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth doing the inverse. escape everything per default and add a helper function to work with a raw value.

that way you get XSS protection by default

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Additional functionality

Development

Successfully merging this pull request may close these issues.

4 participants